Skip to content
File

Blob: test/adapter.test.ts

typescript428 lines
1// @vitest-environment jsdom
2 
3import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4 
5const ACTIVE_BURST_MS = 250;
6const CURSOR_PULSE_MS = 530;
7 
8const mocks = vi.hoisted(() => {
9 const createDisposable = () => ({ dispose: vi.fn() });
10 
11 const state: {
12 lastTerminal: MockTerminal | null;
13 lastFitAddon: MockFitAddon | null;
14 } = {
15 lastTerminal: null,
16 lastFitAddon: null,
17 };
18 
19 class MockTerminal {
20 cols = 80;
21 rows = 24;
22 animationFrameId: number | undefined;
23 element: HTMLElement | undefined;
24 options: { fontFamily: string; fontSize: number };
25 customKeyHandler: ((event: KeyboardEvent) => boolean) | null = null;
26 scrollListener: (() => void) | null = null;
27 selectionListener: (() => void) | null = null;
28 renderer = {
29 getMetrics: vi.fn(() => ({ width: 8, height: 16, baseline: 12 })),
30 remeasureFont: vi.fn(),
31 render: vi.fn(),
32 resize: vi.fn(),
33 };
34 hasSelection = vi.fn(() => false);
35 input = vi.fn();
36 loadAddon = vi.fn();
37 write = vi.fn();
38 onData = vi.fn();
39 onResize = vi.fn();
40 reset = vi.fn();
41 dispose = vi.fn();
42 viewportY = 0;
43 wasmTerm = { id: "mock-wasm" };
44 onScroll = vi.fn((listener: () => void) => {
45 this.scrollListener = listener;
46 return createDisposable();
47 });
48 onSelectionChange = vi.fn((listener: () => void) => {
49 this.selectionListener = listener;
50 return createDisposable();
51 });
52 startRenderLoop = vi.fn(() => {
53 if (typeof this.animationFrameId === "number") return;
54 this.animationFrameId = requestAnimationFrame(() => {
55 this.animationFrameId = undefined;
56 });
57 });
58 open = vi.fn((container: HTMLElement) => {
59 this.element = container;
60 this.startRenderLoop();
61 });
62 focus = vi.fn(() => {
63 this.element?.focus();
64 });
65 
66 constructor(options: { fontFamily?: string; fontSize?: number } = {}) {
67 this.options = {
68 fontFamily: options.fontFamily ?? '"JetBrains Mono", "SF Mono", "Fira Code", monospace',
69 fontSize: options.fontSize ?? 14,
70 };
71 state.lastTerminal = this;
72 }
73 
74 attachCustomKeyEventHandler(handler: (event: KeyboardEvent) => boolean): void {
75 this.customKeyHandler = handler;
76 }
77 }
78 
79 class MockFitAddon {
80 fit = vi.fn();
81 
82 constructor() {
83 state.lastFitAddon = this;
84 }
85 }
86 
87 return { state, MockTerminal, MockFitAddon };
88});
89 
90vi.mock("ghostty-web", () => ({
91 Terminal: mocks.MockTerminal,
92 FitAddon: mocks.MockFitAddon,
93}));
94 
95import { GhosttyTerminalAdapter } from "../src/client/terminal/adapter";
96 
97describe("GhosttyTerminalAdapter", () => {
98 beforeEach(() => {
99 vi.useFakeTimers();
100 document.body.innerHTML = "";
101 mocks.state.lastTerminal = null;
102 mocks.state.lastFitAddon = null;
103 vi.clearAllMocks();
104 setVisibilityState("visible");
105 Object.defineProperty(document, "fonts", {
106 configurable: true,
107 value: undefined,
108 });
109 let nextRafId = 0;
110 const rafTimers = new Map<number, ReturnType<typeof setTimeout>>();
111 vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
112 const rafId = ++nextRafId;
113 const timer = window.setTimeout(() => {
114 rafTimers.delete(rafId);
115 cb(performance.now());
116 }, 16);
117 rafTimers.set(rafId, timer);
118 return rafId;
119 });
120 vi.stubGlobal("cancelAnimationFrame", (id: number) => {
121 const timer = rafTimers.get(id);
122 if (timer) {
123 clearTimeout(timer);
124 rafTimers.delete(id);
125 }
126 });
127 });
128 
129 afterEach(() => {
130 vi.useRealTimers();
131 vi.unstubAllGlobals();
132 setVisibilityState("visible");
133 });
134 
135 it("maps Ctrl+C to ETX input", () => {
136 const { adapter } = mountAdapter();
137 
138 expect(mocks.state.lastTerminal).not.toBeNull();
139 const handled = mocks.state.lastTerminal!.customKeyHandler!(
140 new KeyboardEvent("keydown", {
141 key: "c",
142 ctrlKey: true,
143 }),
144 );
145 
146 expect(handled).toBe(true);
147 expect(mocks.state.lastTerminal!.input).toHaveBeenCalledWith("\x03", true);
148 adapter.dispose();
149 });
150 
151 it("lets Ctrl+V pass through unchanged", () => {
152 const { adapter } = mountAdapter();
153 
154 expect(mocks.state.lastTerminal).not.toBeNull();
155 const handled = mocks.state.lastTerminal!.customKeyHandler!(
156 new KeyboardEvent("keydown", {
157 key: "v",
158 ctrlKey: true,
159 }),
160 );
161 
162 expect(handled).toBe(false);
163 expect(mocks.state.lastTerminal!.input).not.toHaveBeenCalled();
164 adapter.dispose();
165 });
166 
167 it("does not synthesize Ctrl+C when terminal text is selected", () => {
168 const { adapter } = mountAdapter();
169 
170 expect(mocks.state.lastTerminal).not.toBeNull();
171 mocks.state.lastTerminal!.hasSelection.mockReturnValue(true);
172 const handled = mocks.state.lastTerminal!.customKeyHandler!(
173 new KeyboardEvent("keydown", {
174 key: "c",
175 ctrlKey: true,
176 }),
177 );
178 
179 expect(handled).toBe(false);
180 expect(mocks.state.lastTerminal!.input).not.toHaveBeenCalled();
181 adapter.dispose();
182 });
183 
184 it("resumes the native render loop on output without directly painting the renderer", () => {
185 const { adapter, terminal } = mountAdapter();
186 
187 vi.advanceTimersByTime(ACTIVE_BURST_MS);
188 terminal.startRenderLoop.mockClear();
189 terminal.renderer.render.mockClear();
190 
191 adapter.write("hello");
192 
193 expect(terminal.write).toHaveBeenCalledWith("hello");
194 expect(terminal.startRenderLoop).toHaveBeenCalledTimes(1);
195 expect(terminal.renderer.render).not.toHaveBeenCalled();
196 
197 adapter.dispose();
198 });
199 
200 it("pauses immediately when the document becomes hidden", () => {
201 const { adapter, terminal } = mountAdapter();
202 
203 expect(terminal.animationFrameId).toBeDefined();
204 
205 setVisibilityState("hidden");
206 document.dispatchEvent(new Event("visibilitychange"));
207 
208 expect(terminal.animationFrameId).toBeUndefined();
209 
210 adapter.dispose();
211 });
212 
213 it("starts the cursor pulse timer on focus", () => {
214 const { adapter, container } = mountAdapter();
215 
216 vi.advanceTimersByTime(ACTIVE_BURST_MS);
217 const timerCountBeforeFocus = vi.getTimerCount();
218 
219 adapter.focus();
220 setActiveElement(container);
221 container.dispatchEvent(new Event("focusin", { bubbles: true }));
222 expect(vi.getTimerCount()).toBeGreaterThan(timerCountBeforeFocus);
223 
224 adapter.dispose();
225 });
226 
227 it("stops cursor pulses after focus leaves the terminal", () => {
228 const { adapter, container, terminal } = mountAdapter();
229 
230 adapter.focus();
231 setActiveElement(container);
232 container.dispatchEvent(new Event("focusin", { bubbles: true }));
233 vi.advanceTimersByTime(ACTIVE_BURST_MS);
234 terminal.startRenderLoop.mockClear();
235 
236 setActiveElement(document.body);
237 container.dispatchEvent(new Event("focusout", { bubbles: true }));
238 terminal.startRenderLoop.mockClear();
239 
240 vi.advanceTimersByTime(CURSOR_PULSE_MS);
241 
242 expect(terminal.startRenderLoop).not.toHaveBeenCalled();
243 
244 adapter.dispose();
245 });
246 
247 it("refits once fonts finish loading after mount", async () => {
248 let resolveFonts!: () => void;
249 Object.defineProperty(document, "fonts", {
250 configurable: true,
251 value: {
252 ready: new Promise<void>((resolve) => {
253 resolveFonts = resolve;
254 }),
255 },
256 });
257 
258 const { adapter } = mountAdapter();
259 expect(mocks.state.lastFitAddon).not.toBeNull();
260 
261 vi.advanceTimersByTime(16);
262 vi.clearAllMocks();
263 
264 resolveFonts();
265 await Promise.resolve();
266 
267 expect(mocks.state.lastFitAddon!.fit).toHaveBeenCalledTimes(1);
268 
269 adapter.dispose();
270 });
271 
272 it("prefers actual glyph bounds when normalizing renderer font metrics", () => {
273 const originalGetContext = HTMLCanvasElement.prototype.getContext;
274 HTMLCanvasElement.prototype.getContext = vi.fn(() => ({
275 font: "",
276 measureText: vi.fn((text: string) => {
277 if (text === "M") {
278 return { width: 8.2 };
279 }
280 return {
281 actualBoundingBoxAscent: 10.4,
282 actualBoundingBoxDescent: 2.7,
283 emHeightAscent: 11.2,
284 emHeightDescent: 3.1,
285 fontBoundingBoxAscent: 12.1,
286 fontBoundingBoxDescent: 3.4,
287 width: 16.4,
288 };
289 }),
290 })) as typeof HTMLCanvasElement.prototype.getContext;
291 
292 try {
293 const { adapter, terminal } = mountAdapter();
294 const measureFont = terminal.renderer.measureFont as () => {
295 baseline: number;
296 height: number;
297 width: number;
298 };
299 
300 expect(terminal.renderer.remeasureFont).toHaveBeenCalledTimes(1);
301 expect(terminal.renderer.resize).toHaveBeenCalledWith(80, 24);
302 expect(terminal.renderer.render).toHaveBeenCalledWith(terminal.wasmTerm, true, 0, terminal);
303 expect(measureFont()).toEqual({
304 width: 9,
305 height: 16,
306 baseline: 12,
307 });
308 
309 adapter.dispose();
310 } finally {
311 HTMLCanvasElement.prototype.getContext = originalGetContext;
312 }
313 });
314 
315 it("falls back to font bounding boxes when glyph bounds are unavailable", () => {
316 const originalGetContext = HTMLCanvasElement.prototype.getContext;
317 HTMLCanvasElement.prototype.getContext = vi.fn(() => ({
318 font: "",
319 measureText: vi.fn((text: string) => {
320 if (text === "M") {
321 return { width: 8.2 };
322 }
323 return {
324 actualBoundingBoxAscent: 0,
325 actualBoundingBoxDescent: 0,
326 emHeightAscent: 11.2,
327 emHeightDescent: 3.1,
328 fontBoundingBoxAscent: 12.1,
329 fontBoundingBoxDescent: 3.4,
330 width: 16.4,
331 };
332 }),
333 })) as typeof HTMLCanvasElement.prototype.getContext;
334 
335 try {
336 const { adapter, terminal } = mountAdapter();
337 const measureFont = terminal.renderer.measureFont as () => {
338 baseline: number;
339 height: number;
340 width: number;
341 };
342 
343 expect(measureFont()).toEqual({
344 width: 9,
345 height: 18,
346 baseline: 14,
347 });
348 
349 adapter.dispose();
350 } finally {
351 HTMLCanvasElement.prototype.getContext = originalGetContext;
352 }
353 });
354 
355 it("prepares initial dimensions before attach", async () => {
356 const { adapter } = mountAdapter();
357 
358 expect(mocks.state.lastTerminal).not.toBeNull();
359 expect(mocks.state.lastFitAddon).not.toBeNull();
360 
361 vi.advanceTimersByTime(16);
362 vi.clearAllMocks();
363 Object.defineProperty(document, "fonts", {
364 configurable: true,
365 value: { ready: Promise.resolve() },
366 });
367 
368 mocks.state.lastFitAddon!.fit.mockImplementation(() => {
369 mocks.state.lastTerminal!.cols = 132;
370 mocks.state.lastTerminal!.rows = 38;
371 });
372 
373 const resultPromise = adapter.prepareForAttach();
374 await vi.advanceTimersByTimeAsync(32);
375 
376 const result = await resultPromise;
377 
378 expect(mocks.state.lastFitAddon!.fit).toHaveBeenCalledTimes(3);
379 expect(result).toEqual({ cols: 132, rows: 38 });
380 
381 adapter.dispose();
382 });
383 
384 it("cancels the native loop and timers on dispose", () => {
385 const { adapter, terminal } = mountAdapter();
386 const cancelSpy = vi.spyOn(globalThis, "cancelAnimationFrame");
387 
388 setActiveElement(terminal.element ?? document.body);
389 terminal.element?.dispatchEvent(new Event("focusin", { bubbles: true }));
390 
391 adapter.dispose();
392 vi.advanceTimersByTime(CURSOR_PULSE_MS + ACTIVE_BURST_MS);
393 
394 expect(cancelSpy).toHaveBeenCalled();
395 expect(terminal.dispose).toHaveBeenCalledTimes(1);
396 expect(terminal.startRenderLoop).toHaveBeenCalledTimes(1);
397 });
398});
399 
400function mountAdapter() {
401 const adapter = new GhosttyTerminalAdapter();
402 const container = document.createElement("div");
403 container.tabIndex = 0;
404 document.body.appendChild(container);
405 adapter.mount(container);
406 
407 const terminal = mocks.state.lastTerminal;
408 if (!terminal) {
409 throw new Error("Mock terminal was not created");
410 }
411 
412 return { adapter, container, terminal };
413}
414 
415function setActiveElement(element: Element | null): void {
416 Object.defineProperty(document, "activeElement", {
417 configurable: true,
418 value: element,
419 });
420}
421 
422function setVisibilityState(value: DocumentVisibilityState): void {
423 Object.defineProperty(document, "visibilityState", {
424 configurable: true,
425 value,
426 });
427}