1// @vitest-environment jsdom
2import * as NodeFS from "node:fs";
3import * as NodeURL from "node:url";
4import { afterEach, beforeAll, beforeEach, expect, it, vi } from "vite-plus/test";
5
6import { GhosttyModule, mountTerminal, type TerminalHandle } from "../src/index.ts";
7
8let module: GhosttyModule;
9let cellWidth = 7.8;
10let cellHeight = 15.6;
11const handles = new Set<TerminalHandle>();
12const decoder = new TextDecoder();
13
14beforeAll(async () => {
15 module = await GhosttyModule.load(
16 new Uint8Array(
17 NodeFS.readFileSync(
18 NodeURL.fileURLToPath(import.meta.url).replace(
19 /test\/dom\.test\.ts$/,
20 "vendor/ghostty-vt.wasm",
21 ),
22 ),
23 ),
24 );
25});
26
27beforeEach(() => {
28 cellWidth = 7.8;
29 cellHeight = 15.6;
30 vi.stubGlobal("requestAnimationFrame", () => 0);
31 vi.stubGlobal("cancelAnimationFrame", () => {});
32 vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
33 vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (
34 this: HTMLElement,
35 ) {
36 if (this.classList.contains("clo-term-measure")) {
37 return new DOMRect(0, 0, cellWidth * 32, cellHeight);
38 }
39 if (this.classList.contains("clo-term-viewport")) {
40 return new DOMRect(
41 80,
42 120,
43 Number.parseFloat(this.style.width),
44 Number.parseFloat(this.style.height),
45 );
46 }
47 return new DOMRect(72, 108, 800, 800);
48 });
49});
50
51afterEach(() => {
52 handles.forEach((handle) => handle.dispose());
53 handles.clear();
54 document.body.replaceChildren();
55 vi.restoreAllMocks();
56 vi.unstubAllGlobals();
57});
58
59async function mount() {
60 const host = document.createElement("div");
61 document.body.append(host);
62 const reports: string[] = [];
63 const handle = mountTerminal(host, module, {
64 scrollbar: false,
65 stickyScroll: false,
66 onData: (bytes) => reports.push(decoder.decode(bytes)),
67 });
68 handles.add(handle);
69 await handle.ready;
70 handle.write("\x1b[?1049h\x1b[?1003h\x1b[?1006h");
71 handle.view.paint();
72 const viewport = host.querySelector<HTMLElement>(".clo-term-viewport")!;
73 const rows = host.querySelector<HTMLElement>(".clo-term-rows")!;
74 return { handle, reports, viewport, rows };
75}
76
77it("reports the rendered cell across a grid with fractional font metrics and after a font change", async () => {
78 const { handle, reports, viewport, rows } = await mount();
79
80 for (const metrics of [
81 [7.8, 15.6],
82 [6.5, 14.3],
83 ]) {
84 [cellWidth, cellHeight] = metrics;
85 handle.view.refreshMetrics();
86 handle.view.paint();
87 const renderedWidth = Number.parseFloat(rows.style.getPropertyValue("--clo-term-cell-w"));
88 const renderedHeight = Number.parseFloat(rows.style.getPropertyValue("--clo-term-cell-h"));
89
90 for (const col of [0, 1, 40, handle.terminal.cols - 1]) {
91 for (const row of [0, 1, 30, handle.terminal.rows - 1]) {
92 for (const fraction of [0.01, 0.5, 0.99]) {
93 const at = {
94 bubbles: true,
95 cancelable: true,
96 clientX: 80 + (col + fraction) * renderedWidth,
97 clientY: 120 + (row + fraction) * renderedHeight,
98 };
99 viewport.dispatchEvent(new MouseEvent("mousedown", at));
100 expect(reports.pop()).toBe(`\x1b[<0;${col + 1};${row + 1}M`);
101 viewport.dispatchEvent(new MouseEvent("mouseup", at));
102 expect(reports.pop()).toBe(`\x1b[<0;${col + 1};${row + 1}m`);
103 }
104 }
105 }
106 }
107});
108
109it("keeps wheel, drag and pixel reports in the same viewport coordinate system", async () => {
110 const { handle, reports, viewport, rows } = await mount();
111 const renderedWidth = Number.parseFloat(rows.style.getPropertyValue("--clo-term-cell-w"));
112 const renderedHeight = Number.parseFloat(rows.style.getPropertyValue("--clo-term-cell-h"));
113 const at = {
114 bubbles: true,
115 cancelable: true,
116 clientX: 80 + 70.5 * renderedWidth,
117 clientY: 120 + 40.5 * renderedHeight,
118 };
119 viewport.dispatchEvent(new MouseEvent("mousemove", { ...at, buttons: 1 }));
120 expect(reports.pop()).toBe("\x1b[<32;71;41M");
121 viewport.dispatchEvent(new WheelEvent("wheel", { ...at, deltaY: 10 }));
122 expect(reports.pop()).toBe("\x1b[<65;71;41M");
123
124 handle.write("\x1b[?1016h");
125 viewport.dispatchEvent(new MouseEvent("mousedown", { ...at, clientX: 311.5, clientY: 582.25 }));
126 expect(reports.pop()).toBe("\x1b[<0;232;462M");
127});
128
129function touchPointer(
130 target: HTMLElement,
131 type: string,
132 clientY: number,
133 options: { pointerId?: number; isPrimary?: boolean; clientX?: number } = {},
134) {
135 const event = new MouseEvent(type, {
136 bubbles: true,
137 cancelable: true,
138 clientX: options.clientX ?? 100,
139 clientY,
140 });
141 Object.defineProperties(event, {
142 pointerType: { value: "touch" },
143 pointerId: { value: options.pointerId ?? 1 },
144 isPrimary: { value: options.isPrimary ?? true },
145 });
146 target.dispatchEvent(event);
147 return event;
148}
149
150it("reports one tap at the touched cell and focuses the terminal input", async () => {
151 const { handle, reports, viewport } = await mount();
152 reports.length = 0;
153 touchPointer(viewport, "pointerdown", 120 + 2.5 * cellHeight, {
154 clientX: 80 + 3.5 * cellWidth,
155 });
156 expect(reports).toEqual([]);
157 touchPointer(viewport, "pointerup", 120 + 2.5 * cellHeight, {
158 clientX: 80 + 3.5 * cellWidth,
159 });
160 touchPointer(viewport, "click", 120 + 2.5 * cellHeight);
161 expect(reports).toEqual(["\x1b[<0;4;3M", "\x1b[<0;4;3m"]);
162 expect(document.activeElement).toBe(handle.view.input);
163});
164
165it("turns touch swipes into mouse wheel rows without also clicking", async () => {
166 const { reports, viewport } = await mount();
167 reports.length = 0;
168 touchPointer(viewport, "pointerdown", 240);
169 touchPointer(viewport, "pointermove", 240 - 2.5 * cellHeight);
170 touchPointer(viewport, "pointermove", 240 - 3.5 * cellHeight);
171 touchPointer(viewport, "pointerup", 240 - 3.5 * cellHeight);
172 expect(reports).toHaveLength(3);
173 expect(reports.every((report) => report.startsWith("\x1b[<65;"))).toBe(true);
174});
175
176it("scrolls normal output on touch without sending terminal input", async () => {
177 const { handle, reports, viewport } = await mount();
178 handle.write("\x1b[?1003l\x1b[?1049l");
179 handle.write(Array.from({ length: 100 }, (_, i) => `line ${i}\r\n`).join(""));
180 handle.view.paint();
181 reports.length = 0;
182 const before = handle.terminal.scrollbar().offset;
183 touchPointer(viewport, "pointerdown", 240);
184 touchPointer(viewport, "pointermove", 240 + 3 * cellHeight);
185 touchPointer(viewport, "pointerup", 240 + 3 * cellHeight);
186 expect(handle.terminal.scrollbar().offset).toBeLessThan(before);
187 expect(reports).toEqual([]);
188});
189
190it.each([
191 { application: false, expected: "\x1b[A" },
192 { application: true, expected: "\x1bOA" },
193])(
194 "scrolls a non-mouse TUI using its cursor-key mode: $application",
195 async ({ application, expected }) => {
196 const { handle, reports, viewport } = await mount();
197 handle.write(`\x1b[?1003l\x1b[?1${application ? "h" : "l"}`);
198 reports.length = 0;
199 touchPointer(viewport, "pointerdown", 240);
200 touchPointer(viewport, "pointermove", 240 + 2.5 * cellHeight);
201 touchPointer(viewport, "pointerup", 240 + 2.5 * cellHeight);
202 expect(reports).toEqual([expected, expected]);
203 },
204);
205
206it("ignores secondary touches and cancelled taps", async () => {
207 const { reports, viewport } = await mount();
208 reports.length = 0;
209 touchPointer(viewport, "pointerdown", 240, { pointerId: 2, isPrimary: false });
210 touchPointer(viewport, "pointerup", 240, { pointerId: 2, isPrimary: false });
211 touchPointer(viewport, "pointerdown", 240);
212 touchPointer(viewport, "pointerdown", 220, { pointerId: 2, isPrimary: false });
213 touchPointer(viewport, "pointermove", 180, { pointerId: 2, isPrimary: false });
214 touchPointer(viewport, "pointercancel", 240);
215 touchPointer(viewport, "pointerup", 240);
216 expect(reports).toEqual([]);
217});