// @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const ACTIVE_BURST_MS = 250; const CURSOR_PULSE_MS = 530; const mocks = vi.hoisted(() => { const createDisposable = () => ({ dispose: vi.fn() }); const state: { lastTerminal: MockTerminal | null; lastFitAddon: MockFitAddon | null; } = { lastTerminal: null, lastFitAddon: null, }; class MockTerminal { cols = 80; rows = 24; animationFrameId: number | undefined; element: HTMLElement | undefined; options: { fontFamily: string; fontSize: number }; customKeyHandler: ((event: KeyboardEvent) => boolean) | null = null; scrollListener: (() => void) | null = null; selectionListener: (() => void) | null = null; renderer = { getMetrics: vi.fn(() => ({ width: 8, height: 16, baseline: 12 })), remeasureFont: vi.fn(), render: vi.fn(), resize: vi.fn(), }; hasSelection = vi.fn(() => false); input = vi.fn(); loadAddon = vi.fn(); write = vi.fn(); onData = vi.fn(); onResize = vi.fn(); reset = vi.fn(); dispose = vi.fn(); viewportY = 0; wasmTerm = { id: "mock-wasm" }; onScroll = vi.fn((listener: () => void) => { this.scrollListener = listener; return createDisposable(); }); onSelectionChange = vi.fn((listener: () => void) => { this.selectionListener = listener; return createDisposable(); }); startRenderLoop = vi.fn(() => { if (typeof this.animationFrameId === "number") return; this.animationFrameId = requestAnimationFrame(() => { this.animationFrameId = undefined; }); }); open = vi.fn((container: HTMLElement) => { this.element = container; this.startRenderLoop(); }); focus = vi.fn(() => { this.element?.focus(); }); constructor(options: { fontFamily?: string; fontSize?: number } = {}) { this.options = { fontFamily: options.fontFamily ?? '"JetBrains Mono", "SF Mono", "Fira Code", monospace', fontSize: options.fontSize ?? 14, }; state.lastTerminal = this; } attachCustomKeyEventHandler(handler: (event: KeyboardEvent) => boolean): void { this.customKeyHandler = handler; } } class MockFitAddon { fit = vi.fn(); constructor() { state.lastFitAddon = this; } } return { state, MockTerminal, MockFitAddon }; }); vi.mock("ghostty-web", () => ({ Terminal: mocks.MockTerminal, FitAddon: mocks.MockFitAddon, })); import { GhosttyTerminalAdapter } from "../src/client/terminal/adapter"; describe("GhosttyTerminalAdapter", () => { beforeEach(() => { vi.useFakeTimers(); document.body.innerHTML = ""; mocks.state.lastTerminal = null; mocks.state.lastFitAddon = null; vi.clearAllMocks(); setVisibilityState("visible"); Object.defineProperty(document, "fonts", { configurable: true, value: undefined, }); let nextRafId = 0; const rafTimers = new Map>(); vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => { const rafId = ++nextRafId; const timer = window.setTimeout(() => { rafTimers.delete(rafId); cb(performance.now()); }, 16); rafTimers.set(rafId, timer); return rafId; }); vi.stubGlobal("cancelAnimationFrame", (id: number) => { const timer = rafTimers.get(id); if (timer) { clearTimeout(timer); rafTimers.delete(id); } }); }); afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); setVisibilityState("visible"); }); it("maps Ctrl+C to ETX input", () => { const { adapter } = mountAdapter(); expect(mocks.state.lastTerminal).not.toBeNull(); const handled = mocks.state.lastTerminal!.customKeyHandler!( new KeyboardEvent("keydown", { key: "c", ctrlKey: true, }), ); expect(handled).toBe(true); expect(mocks.state.lastTerminal!.input).toHaveBeenCalledWith("\x03", true); adapter.dispose(); }); it("lets Ctrl+V pass through unchanged", () => { const { adapter } = mountAdapter(); expect(mocks.state.lastTerminal).not.toBeNull(); const handled = mocks.state.lastTerminal!.customKeyHandler!( new KeyboardEvent("keydown", { key: "v", ctrlKey: true, }), ); expect(handled).toBe(false); expect(mocks.state.lastTerminal!.input).not.toHaveBeenCalled(); adapter.dispose(); }); it("does not synthesize Ctrl+C when terminal text is selected", () => { const { adapter } = mountAdapter(); expect(mocks.state.lastTerminal).not.toBeNull(); mocks.state.lastTerminal!.hasSelection.mockReturnValue(true); const handled = mocks.state.lastTerminal!.customKeyHandler!( new KeyboardEvent("keydown", { key: "c", ctrlKey: true, }), ); expect(handled).toBe(false); expect(mocks.state.lastTerminal!.input).not.toHaveBeenCalled(); adapter.dispose(); }); it("resumes the native render loop on output without directly painting the renderer", () => { const { adapter, terminal } = mountAdapter(); vi.advanceTimersByTime(ACTIVE_BURST_MS); terminal.startRenderLoop.mockClear(); terminal.renderer.render.mockClear(); adapter.write("hello"); expect(terminal.write).toHaveBeenCalledWith("hello"); expect(terminal.startRenderLoop).toHaveBeenCalledTimes(1); expect(terminal.renderer.render).not.toHaveBeenCalled(); adapter.dispose(); }); it("pauses immediately when the document becomes hidden", () => { const { adapter, terminal } = mountAdapter(); expect(terminal.animationFrameId).toBeDefined(); setVisibilityState("hidden"); document.dispatchEvent(new Event("visibilitychange")); expect(terminal.animationFrameId).toBeUndefined(); adapter.dispose(); }); it("starts the cursor pulse timer on focus", () => { const { adapter, container } = mountAdapter(); vi.advanceTimersByTime(ACTIVE_BURST_MS); const timerCountBeforeFocus = vi.getTimerCount(); adapter.focus(); setActiveElement(container); container.dispatchEvent(new Event("focusin", { bubbles: true })); expect(vi.getTimerCount()).toBeGreaterThan(timerCountBeforeFocus); adapter.dispose(); }); it("stops cursor pulses after focus leaves the terminal", () => { const { adapter, container, terminal } = mountAdapter(); adapter.focus(); setActiveElement(container); container.dispatchEvent(new Event("focusin", { bubbles: true })); vi.advanceTimersByTime(ACTIVE_BURST_MS); terminal.startRenderLoop.mockClear(); setActiveElement(document.body); container.dispatchEvent(new Event("focusout", { bubbles: true })); terminal.startRenderLoop.mockClear(); vi.advanceTimersByTime(CURSOR_PULSE_MS); expect(terminal.startRenderLoop).not.toHaveBeenCalled(); adapter.dispose(); }); it("refits once fonts finish loading after mount", async () => { let resolveFonts!: () => void; Object.defineProperty(document, "fonts", { configurable: true, value: { ready: new Promise((resolve) => { resolveFonts = resolve; }), }, }); const { adapter } = mountAdapter(); expect(mocks.state.lastFitAddon).not.toBeNull(); vi.advanceTimersByTime(16); vi.clearAllMocks(); resolveFonts(); await Promise.resolve(); expect(mocks.state.lastFitAddon!.fit).toHaveBeenCalledTimes(1); adapter.dispose(); }); it("prefers actual glyph bounds when normalizing renderer font metrics", () => { const originalGetContext = HTMLCanvasElement.prototype.getContext; HTMLCanvasElement.prototype.getContext = vi.fn(() => ({ font: "", measureText: vi.fn((text: string) => { if (text === "M") { return { width: 8.2 }; } return { actualBoundingBoxAscent: 10.4, actualBoundingBoxDescent: 2.7, emHeightAscent: 11.2, emHeightDescent: 3.1, fontBoundingBoxAscent: 12.1, fontBoundingBoxDescent: 3.4, width: 16.4, }; }), })) as typeof HTMLCanvasElement.prototype.getContext; try { const { adapter, terminal } = mountAdapter(); const measureFont = terminal.renderer.measureFont as () => { baseline: number; height: number; width: number; }; expect(terminal.renderer.remeasureFont).toHaveBeenCalledTimes(1); expect(terminal.renderer.resize).toHaveBeenCalledWith(80, 24); expect(terminal.renderer.render).toHaveBeenCalledWith(terminal.wasmTerm, true, 0, terminal); expect(measureFont()).toEqual({ width: 9, height: 16, baseline: 12, }); adapter.dispose(); } finally { HTMLCanvasElement.prototype.getContext = originalGetContext; } }); it("falls back to font bounding boxes when glyph bounds are unavailable", () => { const originalGetContext = HTMLCanvasElement.prototype.getContext; HTMLCanvasElement.prototype.getContext = vi.fn(() => ({ font: "", measureText: vi.fn((text: string) => { if (text === "M") { return { width: 8.2 }; } return { actualBoundingBoxAscent: 0, actualBoundingBoxDescent: 0, emHeightAscent: 11.2, emHeightDescent: 3.1, fontBoundingBoxAscent: 12.1, fontBoundingBoxDescent: 3.4, width: 16.4, }; }), })) as typeof HTMLCanvasElement.prototype.getContext; try { const { adapter, terminal } = mountAdapter(); const measureFont = terminal.renderer.measureFont as () => { baseline: number; height: number; width: number; }; expect(measureFont()).toEqual({ width: 9, height: 18, baseline: 14, }); adapter.dispose(); } finally { HTMLCanvasElement.prototype.getContext = originalGetContext; } }); it("prepares initial dimensions before attach", async () => { const { adapter } = mountAdapter(); expect(mocks.state.lastTerminal).not.toBeNull(); expect(mocks.state.lastFitAddon).not.toBeNull(); vi.advanceTimersByTime(16); vi.clearAllMocks(); Object.defineProperty(document, "fonts", { configurable: true, value: { ready: Promise.resolve() }, }); mocks.state.lastFitAddon!.fit.mockImplementation(() => { mocks.state.lastTerminal!.cols = 132; mocks.state.lastTerminal!.rows = 38; }); const resultPromise = adapter.prepareForAttach(); await vi.advanceTimersByTimeAsync(32); const result = await resultPromise; expect(mocks.state.lastFitAddon!.fit).toHaveBeenCalledTimes(3); expect(result).toEqual({ cols: 132, rows: 38 }); adapter.dispose(); }); it("cancels the native loop and timers on dispose", () => { const { adapter, terminal } = mountAdapter(); const cancelSpy = vi.spyOn(globalThis, "cancelAnimationFrame"); setActiveElement(terminal.element ?? document.body); terminal.element?.dispatchEvent(new Event("focusin", { bubbles: true })); adapter.dispose(); vi.advanceTimersByTime(CURSOR_PULSE_MS + ACTIVE_BURST_MS); expect(cancelSpy).toHaveBeenCalled(); expect(terminal.dispose).toHaveBeenCalledTimes(1); expect(terminal.startRenderLoop).toHaveBeenCalledTimes(1); }); }); function mountAdapter() { const adapter = new GhosttyTerminalAdapter(); const container = document.createElement("div"); container.tabIndex = 0; document.body.appendChild(container); adapter.mount(container); const terminal = mocks.state.lastTerminal; if (!terminal) { throw new Error("Mock terminal was not created"); } return { adapter, container, terminal }; } function setActiveElement(element: Element | null): void { Object.defineProperty(document, "activeElement", { configurable: true, value: element, }); } function setVisibilityState(value: DocumentVisibilityState): void { Object.defineProperty(document, "visibilityState", { configurable: true, value, }); }