1// @vitest-environment jsdom
2// @effect-diagnostics nodeBuiltinImport:off - loads the vendored WASM for isolated DOM tests.
3import * as NodeFS from "node:fs";
4import * as NodePath from "node:path";
5import * as NodeURL from "node:url";
6import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
7
8import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks";
9
10import { writeTextToClipboard } from "~/hooks/useCopyToClipboard";
11import { TerminalSurface, type TerminalSurfaceOptions } from "./TerminalSurface";
12
13vi.mock("~/hooks/useCopyToClipboard", () => ({ writeTextToClipboard: vi.fn(async () => {}) }));
14
15const bytes = new Uint8Array(
16 NodeFS.readFileSync(
17 NodePath.resolve(
18 NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)),
19 "../../../../packages/terminal/vendor/ghostty-vt.wasm",
20 ),
21 ),
22);
23const surfaces = new Set<TerminalSurface>();
24const frames = new Map<number, FrameRequestCallback>();
25let nextFrame = 0;
26let width = 640;
27let height = 192;
28let platform = "MacIntel";
29
30function paint() {
31 const pending = [...frames.values()];
32 frames.clear();
33 pending.forEach((callback) => callback(performance.now()));
34}
35
36async function mount(
37 options: Partial<TerminalSurfaceOptions> = {},
38 host = document.createElement("div"),
39) {
40 document.body.append(host);
41 const onData = vi.fn();
42 const onResize = vi.fn();
43 const onLinkActivate = vi.fn();
44 const onContextMenu = vi.fn();
45 const surface = await TerminalSurface.create(host, {
46 theme: {
47 foreground: "#ffffff",
48 background: "#000000",
49 cursor: "#dddddd",
50 palette: Array(16).fill("#aaaaaa"),
51 selectionBackground: "#333333",
52 },
53 onData,
54 onResize,
55 onLinkActivate,
56 onContextMenu,
57 beforeKey: () => true,
58 ...options,
59 });
60 surfaces.add(surface);
61 paint();
62 const terminalRoot = surface.input.parentElement!;
63 return { surface, host: terminalRoot, onData, onResize, onLinkActivate, onContextMenu };
64}
65
66function select(host: HTMLElement, text = "hello") {
67 const row = [...host.querySelectorAll(".clo-term-row")].find((row) =>
68 row.textContent?.includes(text),
69 );
70 expect(row).toBeDefined();
71 const walker = document.createTreeWalker(row!, NodeFilter.SHOW_TEXT);
72 const range = document.createRange();
73 const start = row!.textContent!.indexOf(text);
74 const end = start + text.length;
75 let offset = 0;
76 let node = walker.nextNode();
77 while (node) {
78 const length = node.textContent!.length;
79 if (offset <= start && start < offset + length) range.setStart(node, start - offset);
80 if (offset < end && end <= offset + length) {
81 range.setEnd(node, end - offset);
82 break;
83 }
84 offset += length;
85 node = walker.nextNode();
86 }
87 const selection = document.getSelection()!;
88 selection.removeAllRanges();
89 selection.addRange(range);
90}
91
92// Model the Chromium textarea focus behavior that loses native output selection.
93function nativeInputFocus(surface: TerminalSurface) {
94 return vi.spyOn(surface.input, "focus").mockImplementation(function (
95 this: HTMLTextAreaElement,
96 options?: FocusOptions,
97 ) {
98 HTMLElement.prototype.focus.call(this, options);
99 this.ownerDocument.getSelection()?.removeAllRanges();
100 });
101}
102
103function key(element: HTMLElement, key: string, options: KeyboardEventInit = {}) {
104 const event = new KeyboardEvent("keydown", {
105 key,
106 code: key === "c" ? "KeyC" : key,
107 bubbles: true,
108 cancelable: true,
109 ...options,
110 });
111 element.dispatchEvent(event);
112 return event;
113}
114
115beforeEach(() => {
116 document.body.tabIndex = -1;
117 document.body.focus();
118 frames.clear();
119 width = 640;
120 height = 192;
121 platform = "MacIntel";
122 vi.spyOn(navigator, "platform", "get").mockImplementation(() => platform);
123 vi.stubGlobal(
124 "fetch",
125 vi.fn(async () => new Response(bytes)),
126 );
127 vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
128 const id = ++nextFrame;
129 frames.set(id, callback);
130 return id;
131 });
132 vi.stubGlobal("cancelAnimationFrame", (id: number) => frames.delete(id));
133 vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
134 vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (
135 this: HTMLElement,
136 ) {
137 return this.classList.contains("clo-term-measure")
138 ? new DOMRect(0, 0, 256, 16)
139 : new DOMRect(0, 0, width, height);
140 });
141});
142
143afterEach(() => {
144 surfaces.forEach((surface) => surface.dispose());
145 surfaces.clear();
146 document.body.replaceChildren();
147 document.body.removeAttribute("tabindex");
148 vi.restoreAllMocks();
149 vi.unstubAllGlobals();
150 vi.clearAllMocks();
151});
152
153describe("T3 DOM terminal adapter", () => {
154 it("renders real VT output as selectable DOM and leaves selection unchanged on unrelated output", async () => {
155 const { surface, host, onContextMenu } = await mount();
156 surface.write("hello 🌱\r\n");
157 paint();
158 expect(host.textContent).toContain("hello 🌱");
159 expect(host.querySelector("canvas")).toBeNull();
160 select(host);
161 document.dispatchEvent(new Event("selectionchange"));
162 expect(surface.getSelection()).toBe("hello");
163 expect(surface.getSelectionPosition()).toEqual({ start: { y: 0 }, end: { y: 0 } });
164 surface.write("next line");
165 paint();
166 expect(surface.getSelection()).toBe("hello");
167 expect(onContextMenu).not.toHaveBeenCalled();
168 host.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true }));
169 expect(onContextMenu).toHaveBeenCalledOnce();
170 });
171
172 it("copies only an owned DOM selection and keeps Ctrl-C as SIGINT without selection", async () => {
173 const { surface, host, onData } = await mount();
174 surface.write("hello");
175 paint();
176 select(host);
177 expect(key(surface.input, "c", { metaKey: true }).defaultPrevented).toBe(true);
178 await Promise.resolve();
179 expect(writeTextToClipboard).toHaveBeenCalledExactlyOnceWith("hello", "terminal selection");
180 expect(onData).not.toHaveBeenCalled();
181 surface.clearSelection();
182 key(surface.input, "c", { ctrlKey: true });
183 expect(onData).toHaveBeenCalledExactlyOnceWith("\x03");
184 vi.mocked(writeTextToClipboard).mockClear();
185 const composer = document.createElement("textarea");
186 document.body.append(composer);
187 key(composer, "c", { metaKey: true });
188 expect(writeTextToClipboard).not.toHaveBeenCalled();
189 });
190
191 it("commits IME once, brackets paste and sends control keys through the VT encoder", async () => {
192 const { surface, onData } = await mount();
193 const input = surface.input;
194 input.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true }));
195 key(input, "Process", { isComposing: true, keyCode: 229 });
196 input.value = "にほん";
197 input.dispatchEvent(
198 new InputEvent("input", {
199 bubbles: true,
200 isComposing: true,
201 inputType: "insertCompositionText",
202 }),
203 );
204 input.dispatchEvent(new CompositionEvent("compositionend", { bubbles: true, data: "日本" }));
205 input.value = "日本";
206 input.dispatchEvent(
207 new InputEvent("input", { bubbles: true, inputType: "insertFromComposition" }),
208 );
209 expect(onData).toHaveBeenCalledExactlyOnceWith("日本");
210 onData.mockClear();
211 surface.write("\x1b[?2004h");
212 const paste = new Event("paste", { bubbles: true, cancelable: true });
213 Object.defineProperty(paste, "clipboardData", { value: { getData: () => "a🌱b" } });
214 input.dispatchEvent(paste);
215 expect(onData).toHaveBeenCalledExactlyOnceWith("\x1b[200~a🌱b\x1b[201~");
216 onData.mockClear();
217 key(input, "Backspace");
218 expect(onData).toHaveBeenCalledExactlyOnceWith("\x7f");
219 });
220
221 it("keeps read-only terminals selectable and blocks native keyboard and IME writes", async () => {
222 const { surface, host, onData } = await mount();
223 surface.write("hello");
224 paint();
225 surface.input.readOnly = true;
226 select(host);
227 key(surface.input, "c", { metaKey: true });
228 key(surface.input, "c", { ctrlKey: true });
229 surface.clearSelection();
230 key(surface.input, "Enter");
231 surface.input.dispatchEvent(
232 new CompositionEvent("compositionend", { data: "秘密", bubbles: true }),
233 );
234 expect(onData).not.toHaveBeenCalled();
235 expect(writeTextToClipboard).toHaveBeenCalled();
236 });
237
238 it("requires modifier-click and preserves OSC 8 identity and file positions", async () => {
239 const { surface, host, onLinkActivate } = await mount({
240 validatePath: (path) => path === "src/file.ts",
241 });
242 surface.write(
243 "\x1b]8;;https://example.test/oauth\x1b\\sign in\x1b]8;;\x1b\\\r\nsrc/file.ts:12:3",
244 );
245 paint();
246 const links = host.querySelectorAll(".clo-term-link");
247 expect(links.length).toBe(2);
248 links[0]!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
249 expect(onLinkActivate).not.toHaveBeenCalled();
250 links[0]!.dispatchEvent(
251 new MouseEvent("click", { bubbles: true, cancelable: true, metaKey: true }),
252 );
253 expect(onLinkActivate.mock.calls[0]?.[0]).toBe("https://example.test/oauth");
254 platform = "Linux x86_64";
255 links[1]!.dispatchEvent(
256 new MouseEvent("click", { bubbles: true, cancelable: true, ctrlKey: true }),
257 );
258 expect(onLinkActivate.mock.calls[1]?.[0]).toBe("src/file.ts:12:3");
259 });
260
261 it("denies program clipboard writes without touching the system clipboard", async () => {
262 const { surface } = await mount();
263 surface.write("\x1b]52;c;c2VjcmV0\x07");
264 expect(writeTextToClipboard).not.toHaveBeenCalled();
265 });
266
267 it("recognizes uppercase HTTP schemes and honors live link permission", async () => {
268 let allowed = true;
269 const { surface, host, onLinkActivate } = await mount({ canActivateLink: () => allowed });
270 surface.write("HTTPS://example.test/login");
271 paint();
272 const link = host.querySelector(".clo-term-link")!;
273 expect(link).not.toBeNull();
274 allowed = false;
275 link.dispatchEvent(new MouseEvent("click", { bubbles: true, metaKey: true }));
276 expect(onLinkActivate).not.toHaveBeenCalled();
277 allowed = true;
278 link.dispatchEvent(new MouseEvent("click", { bubbles: true, metaKey: true }));
279 expect(onLinkActivate.mock.calls[0]?.[0]).toBe("HTTPS://example.test/login");
280 });
281
282 it("activates complete URLs from either fragment of a wrapped line", async () => {
283 width = 160;
284 const { surface, host, onLinkActivate } = await mount();
285 const url = "https://example.test/long/path/that/wraps";
286 surface.write(url);
287 paint();
288 const links = host.querySelectorAll(".clo-term-link");
289 expect(links.length).toBeGreaterThan(1);
290 links[0]!.dispatchEvent(new MouseEvent("click", { bubbles: true, metaKey: true }));
291 links[1]!.dispatchEvent(new MouseEvent("click", { bubbles: true, metaKey: true }));
292 expect(onLinkActivate.mock.calls.map(([target]) => target)).toEqual([url, url]);
293 });
294
295 it("lets application shortcuts and native Tab bubble without VT encoding", async () => {
296 const { surface, host, onData } = await mount({ beforeKey: () => false });
297 const parent = vi.fn();
298 host.parentElement!.addEventListener("keydown", parent);
299 expect(key(surface.input, "Tab").defaultPrevented).toBe(false);
300 expect(parent).toHaveBeenCalledOnce();
301 expect(onData).not.toHaveBeenCalled();
302 });
303
304 it("suppresses replay replies while live queries still reach the PTY", async () => {
305 const { surface, onData } = await mount();
306 surface.resetAndWrite("hello\x1b[6n");
307 expect(onData).not.toHaveBeenCalled();
308 surface.write("\x1b[6n");
309 expect(onData).toHaveBeenCalledExactlyOnceWith("\x1b[1;6R");
310 });
311
312 it("keeps output drag, pane activation, modifier keys and copy on the root until actual typing", async () => {
313 const { surface, host, onData, onContextMenu } = await mount();
314 surface.write("hello");
315 paint();
316 const inputFocus = nativeInputFocus(surface);
317 const row = host.querySelector(".clo-term-row")!;
318 row.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true }));
319 expect(document.activeElement).toBe(host);
320 surface.focus();
321 expect(inputFocus).not.toHaveBeenCalled();
322 select(host);
323 row.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
324 expect(document.activeElement).toBe(host);
325 expect(surface.getSelection()).toBe("hello");
326 key(host, "Shift", { shiftKey: true });
327 key(host, "Control", { ctrlKey: true });
328 key(host, "Alt", { altKey: true });
329 key(host, "Meta", { metaKey: true });
330 key(host, "c", { metaKey: true });
331 expect(writeTextToClipboard).toHaveBeenCalledExactlyOnceWith("hello", "terminal selection");
332 row.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 2 }));
333 host.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true, cancelable: true }));
334 expect(onContextMenu).toHaveBeenCalledOnce();
335 surface.focus();
336 expect(inputFocus).not.toHaveBeenCalled();
337 expect(surface.getSelection()).toBe("hello");
338 key(host, "a");
339 expect(document.activeElement).toBe(surface.input);
340 expect(surface.getSelection()).toBe("");
341 expect(onData).toHaveBeenCalledExactlyOnceWith("a");
342 });
343
344 it("keeps shortcut focus and keyboard input available while terminal fonts load", async () => {
345 const style = document.createElement("style");
346 style.textContent = NodeFS.readFileSync(
347 NodePath.resolve(
348 NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)),
349 "../../../../packages/terminal/src/renderer/terminal.css",
350 ),
351 "utf8",
352 );
353 document.head.append(style);
354 let finishFonts!: () => void;
355 const fontReady = new Promise<void>((resolve) => {
356 finishFonts = resolve;
357 });
358 let reportFontLoad!: () => void;
359 const fontStarted = new Promise<void>((resolve) => {
360 reportFontLoad = resolve;
361 });
362 const fonts = Object.assign(new EventTarget(), {
363 load: async () => {
364 reportFontLoad();
365 await fontReady;
366 return [{}];
367 },
368 check: () => true,
369 });
370 Object.defineProperty(document, "fonts", { configurable: true, value: fonts });
371 const host = document.createElement("div");
372 host.tabIndex = -1;
373 const mounting = mount({}, host);
374 await fontStarted;
375 const focus = HTMLElement.prototype.focus;
376 vi.spyOn(HTMLElement.prototype, "focus").mockImplementation(function (
377 this: HTMLElement,
378 options?: FocusOptions,
379 ) {
380 // Browsers refuse focus on visibility:hidden elements; jsdom does not.
381 if (getComputedStyle(this).visibility !== "hidden") focus.call(this, options);
382 });
383 try {
384 host.focus();
385 const input = host.querySelector("textarea");
386 if (!input) throw new Error("Terminal input missing");
387 expect(document.activeElement).toBe(input);
388 expect(getComputedStyle(host.querySelector(".clo-term-viewport")!).visibility).toBe("hidden");
389 key(input, "a");
390 finishFonts();
391 const { surface, onData } = await mounting;
392 expect(document.activeElement).toBe(surface.input);
393 expect(onData).toHaveBeenCalledExactlyOnceWith("a");
394 expect(getComputedStyle(host.querySelector(".clo-term-viewport")!).visibility).toBe(
395 "visible",
396 );
397 } finally {
398 finishFonts();
399 await mounting;
400 Reflect.deleteProperty(document, "fonts");
401 style.remove();
402 }
403 });
404
405 it("keeps a replacement surface intact when an older async mount finishes and disposes later", async () => {
406 const style = document.createElement("style");
407 style.textContent = ".clo-term { font-family: monospace; font-size: 12px; }";
408 document.head.append(style);
409 let finishFirst!: () => void;
410 let finishSecond!: () => void;
411 let startedFirst!: () => void;
412 let startedSecond!: () => void;
413 const firstReady = new Promise<void>((resolve) => {
414 finishFirst = resolve;
415 });
416 const secondReady = new Promise<void>((resolve) => {
417 finishSecond = resolve;
418 });
419 const firstStarted = new Promise<void>((resolve) => {
420 startedFirst = resolve;
421 });
422 const secondStarted = new Promise<void>((resolve) => {
423 startedSecond = resolve;
424 });
425 let loads = 0;
426 Object.defineProperty(document, "fonts", {
427 configurable: true,
428 value: Object.assign(new EventTarget(), {
429 load: async () => {
430 const first = loads++ < 4;
431 if (first) startedFirst();
432 else startedSecond();
433 await (first ? firstReady : secondReady);
434 return [{}];
435 },
436 check: () => true,
437 }),
438 });
439 const container = document.createElement("div");
440 container.className = "terminal-container";
441 container.setAttribute("aria-label", "Replacement terminal");
442 const first = mount({ font: { size: 12 } }, container);
443 await firstStarted;
444 const second = mount({ font: { size: 14 } }, container);
445 await secondStarted;
446 try {
447 finishSecond();
448 const current = await second;
449 const root = current.surface.input.parentElement!;
450 current.surface.write("new output");
451 paint();
452 finishFirst();
453 const obsolete = await first;
454 obsolete.surface.dispose();
455 expect(root.classList.contains("clo-term")).toBe(true);
456 expect(root.style.getPropertyValue("--clo-term-font-size")).toBe("14px");
457 expect(container.contains(current.surface.input)).toBe(true);
458 expect(root.textContent).toContain("new output");
459 key(current.surface.input, "x");
460 expect(current.onData).toHaveBeenCalledExactlyOnceWith("x");
461 expect(current.surface.input.getAttribute("aria-label")).toBe("Replacement terminal");
462 current.surface.dispose();
463 expect(container.childElementCount).toBe(0);
464 expect(container.className).toBe("terminal-container");
465 } finally {
466 finishFirst();
467 finishSecond();
468 await Promise.all([first, second]);
469 Reflect.deleteProperty(document, "fonts");
470 style.remove();
471 }
472 });
473
474 it("focuses the native input for plain clicks, keyboard focus and first IME entry", async () => {
475 const { surface, host, onData } = await mount();
476 surface.write("hello");
477 paint();
478 const inputFocus = nativeInputFocus(surface);
479 host.focus();
480 expect(document.activeElement).toBe(surface.input);
481 expect(surface.input.getAttribute("aria-label")).toBe("Terminal input");
482 const row = host.querySelector(".clo-term-row")!;
483 row.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
484 expect(document.activeElement).toBe(host);
485 row.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
486 expect(document.activeElement).toBe(surface.input);
487 inputFocus.mockClear();
488 row.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
489 select(host);
490 row.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
491 expect(inputFocus).not.toHaveBeenCalled();
492 key(host, "Process", { keyCode: 229, isComposing: true });
493 expect(document.activeElement).toBe(surface.input);
494 expect(surface.getSelection()).toBe("");
495 surface.input.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true }));
496 surface.input.dispatchEvent(
497 new CompositionEvent("compositionend", { bubbles: true, data: "日本" }),
498 );
499 expect(onData).toHaveBeenCalledExactlyOnceWith("日本");
500 });
501
502 it("moves to native input for browser paste without consuming the shortcut as VT data", async () => {
503 const { surface, host, onData } = await mount();
504 surface.write("hello\x1b[?2004h");
505 paint();
506 nativeInputFocus(surface);
507 const row = host.querySelector(".clo-term-row")!;
508 row.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
509 select(host);
510 row.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
511 const shortcut = key(host, "v", { metaKey: true });
512 expect(shortcut.defaultPrevented).toBe(false);
513 expect(document.activeElement).toBe(surface.input);
514 expect(surface.getSelection()).toBe("");
515 expect(onData).not.toHaveBeenCalled();
516 const paste = new Event("paste", { bubbles: true, cancelable: true });
517 Object.defineProperty(paste, "clipboardData", { value: { getData: () => "日本" } });
518 surface.input.dispatchEvent(paste);
519 expect(onData).toHaveBeenCalledExactlyOnceWith("\x1b[200~日本\x1b[201~");
520 });
521
522 it("focuses TUI input before claiming a press and retains the Shift selection override through release", async () => {
523 const { surface, host, onData } = await mount();
524 surface.write("hello\x1b[?1000h\x1b[?1006h");
525 paint();
526 const inputFocus = nativeInputFocus(surface);
527 const row = host.querySelector(".clo-term-row")!;
528 const press = new MouseEvent("mousedown", { bubbles: true, cancelable: true });
529 row.dispatchEvent(press);
530 expect(press.defaultPrevented).toBe(true);
531 expect(document.activeElement).toBe(surface.input);
532 expect(onData).toHaveBeenCalledOnce();
533 row.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true }));
534 onData.mockClear();
535 inputFocus.mockClear();
536 const selectPress = new MouseEvent("mousedown", {
537 bubbles: true,
538 cancelable: true,
539 shiftKey: true,
540 });
541 row.dispatchEvent(selectPress);
542 expect(selectPress.defaultPrevented).toBe(false);
543 expect(document.activeElement).toBe(host);
544 row.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, cancelable: true, buttons: 1 }));
545 select(host);
546 const release = new MouseEvent("mouseup", { bubbles: true, cancelable: true });
547 row.dispatchEvent(release);
548 expect(release.defaultPrevented).toBe(false);
549 expect(surface.getSelection()).toBe("hello");
550 expect(inputFocus).not.toHaveBeenCalled();
551 expect(onData).not.toHaveBeenCalled();
552 });
553
554 it("finishes or cancels outside output gestures and removes document listeners on dispose", async () => {
555 const { surface, host } = await mount();
556 surface.write("hello");
557 paint();
558 const row = host.querySelector(".clo-term-row")!;
559 const inputFocus = nativeInputFocus(surface);
560 row.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
561 document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
562 expect(document.activeElement).toBe(surface.input);
563 row.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
564 document.dispatchEvent(new Event("pointercancel"));
565 surface.focus();
566 expect(document.activeElement).toBe(surface.input);
567 row.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
568 host.ownerDocument.defaultView!.dispatchEvent(new Event("blur"));
569 surface.focus();
570 expect(document.activeElement).toBe(surface.input);
571 row.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
572 inputFocus.mockClear();
573 surface.dispose();
574 document.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
575 expect(inputFocus).not.toHaveBeenCalled();
576 });
577
578 it("keeps native copy selection through readonly output gestures but allows explicit paste into writable input", async () => {
579 const { surface, host, onData } = await mount();
580 surface.write("hello\x1b[?2004h");
581 paint();
582 nativeInputFocus(surface);
583 const row = host.querySelector(".clo-term-row")!;
584 surface.input.readOnly = true;
585 row.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
586 select(host);
587 row.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
588 key(host, "a");
589 key(host, "Process", { keyCode: 229 });
590 surface.focus();
591 expect(document.activeElement).toBe(host);
592 expect(surface.getSelection()).toBe("hello");
593 expect(onData).not.toHaveBeenCalled();
594 surface.input.readOnly = false;
595 surface.paste("new input");
596 expect(document.activeElement).toBe(surface.input);
597 expect(surface.getSelection()).toBe("");
598 expect(onData).toHaveBeenCalledExactlyOnceWith("\x1b[200~new input\x1b[201~");
599 });
600
601 it("ignores retained async writes and replay after terminal disposal", async () => {
602 const { surface, host, onData } = await mount();
603 let rejectFailure!: (error: Error) => void;
604 const failure = new Promise<void>((_, reject) => {
605 rejectFailure = reject;
606 });
607 const pendingWrite = failure.catch((error: unknown) => surface.write(String(error)));
608 surface.write("hello");
609 surface.dispose();
610 rejectFailure(new Error("late RPC failure"));
611 await expect(pendingWrite).resolves.toBeUndefined();
612 expect(() => surface.resetAndWrite("old history\x1b[6n")).not.toThrow();
613 paint();
614 expect(host.childElementCount).toBe(0);
615 expect(onData).not.toHaveBeenCalled();
616 });
617
618 it("activates complete Windows, UNC and home paths with exact canonical validation", async () => {
619 const paths = [
620 String.raw`C:\repo\src\file.ts`,
621 String.raw`\\server\share\file.ts`,
622 "~/src/file.ts",
623 ];
624 const canonical = new Set([
625 String.raw`C:\repo\src\file.ts`,
626 String.raw`\\server\share\file.ts`,
627 "/Users/clo/src/file.ts",
628 ]);
629 let allowed = true;
630 const validatePath = vi.fn(
631 (path: string) => allowed && canonical.has(resolvePathLinkTarget(path, "/Users/clo/repo")),
632 );
633 const { surface, host, onLinkActivate } = await mount({ validatePath });
634 surface.write(paths.map((path) => `${path}:12:3`).join("\r\n"));
635 paint();
636 const links = [...host.querySelectorAll(".clo-term-link")];
637 expect(links).toHaveLength(3);
638 expect(validatePath.mock.calls.map(([path]) => path)).toEqual(paths);
639 links.forEach((link) => link.dispatchEvent(new MouseEvent("click", { bubbles: true })));
640 expect(onLinkActivate).not.toHaveBeenCalled();
641 links.forEach((link) =>
642 link.dispatchEvent(new MouseEvent("click", { bubbles: true, metaKey: true })),
643 );
644 expect(onLinkActivate.mock.calls.map(([target]) => target)).toEqual(
645 paths.map((path) => `${path}:12:3`),
646 );
647 allowed = false;
648 links[0]!.dispatchEvent(new MouseEvent("click", { bubbles: true, metaKey: true }));
649 expect(onLinkActivate).toHaveBeenCalledTimes(3);
650 });
651
652 it("invalidates a queued frame when hidden without consuming the next visible frame", async () => {
653 const { surface, host } = await mount();
654 surface.write("hello");
655 const stale = [...frames.values()][0]!;
656 frames.clear();
657 surface.setVisible(false);
658 surface.setVisible(true);
659 stale(performance.now());
660 expect(host.textContent).not.toContain("hello");
661 expect(frames.size).toBe(1);
662 surface.write(" world");
663 expect(frames.size).toBe(1);
664 paint();
665 expect(host.textContent).toContain("hello world");
666 });
667
668 it("retains hidden geometry and output until shown, then fits and disposes every DOM node", async () => {
669 const { surface, host, onResize } = await mount({ visible: false });
670 expect(onResize).not.toHaveBeenCalled();
671 surface.write("hello");
672 paint();
673 expect(host.querySelectorAll(".clo-term-row").length).toBe(0);
674 surface.setVisible(true);
675 paint();
676 expect(host.textContent).toContain("hello");
677 expect(onResize).toHaveBeenCalledWith(78, 12);
678 onResize.mockClear();
679 surface.setVisible(false);
680 width = 160;
681 height = 64;
682 expect(surface.fit()).toEqual({ cols: 78, rows: 12 });
683 expect(onResize).not.toHaveBeenCalled();
684 surface.setVisible(true);
685 expect(onResize).toHaveBeenCalledWith(18, 4);
686 surface.dispose();
687 expect(host.childElementCount).toBe(0);
688 expect(() => surface.write("after dispose")).not.toThrow();
689 });
690});