| 1 | // @vitest-environment jsdom |
| 2 | // @effect-diagnostics nodeBuiltinImport:off - loads the vendored WASM for isolated DOM tests. |
| 3 | import * as NodeFS from "node:fs"; |
| 4 | import * as NodePath from "node:path"; |
| 5 | import * as NodeURL from "node:url"; |
| 6 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 7 | |
| 8 | import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks"; |
| 9 | |
| 10 | import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; |
| 11 | import { TerminalSurface, type TerminalSurfaceOptions } from "./TerminalSurface"; |
| 12 | |
| 13 | vi.mock("~/hooks/useCopyToClipboard", () => ({ writeTextToClipboard: vi.fn(async () => {}) })); |
| 14 | |
| 15 | const 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 | ); |
| 23 | const surfaces = new Set<TerminalSurface>(); |
| 24 | const frames = new Map<number, FrameRequestCallback>(); |
| 25 | let nextFrame = 0; |
| 26 | let width = 640; |
| 27 | let height = 192; |
| 28 | let platform = "MacIntel"; |
| 29 | |
| 30 | function paint() { |
| 31 | const pending = [...frames.values()]; |
| 32 | frames.clear(); |
| 33 | pending.forEach((callback) => callback(performance.now())); |
| 34 | } |
| 35 | |
| 36 | async 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 | |
| 66 | function 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. |
| 93 | function 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 | |
| 103 | function 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 | |
| 115 | beforeEach(() => { |
| 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 | |
| 143 | afterEach(() => { |
| 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 | |
| 153 | describe("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 | }); |