import { assertEquals, assertMatch } from "@std/assert"; import { existsSync } from "node:fs"; import type { Channel, ChannelMessage } from "@berry/api/web.ts"; import type { Window } from "happy-dom"; import { until } from "@berry/util"; import { viewContext, withDom } from "@berry/util/dom.ts"; import { Ghostty } from "./ghostty.ts"; import { COLORS } from "./palette.ts"; import { OUTPUT, SNAPSHOT, tagged } from "./protocol.ts"; import { terminalView } from "./view.tsx"; const WASM = new URL("./ghostty-vt.wasm", import.meta.url); const withDocument = (test: (window: Window) => Promise) => withDom(test, { globals: { requestAnimationFrame: (callback: () => void) => setTimeout(callback, 0), cancelAnimationFrame: (handle: number) => clearTimeout(handle), }, }); /** The server's end, played by the test: what the view sent, and a way to answer. */ function server() { const sent: ChannelMessage[] = []; let say!: ReadableStreamDefaultController; const channel: Channel = { send: (message) => void sent.push(message), messages: new ReadableStream({ start: (controller) => void (say = controller) }), close: () => {}, }; return { channel, sent, say: (message: ChannelMessage) => say.enqueue(message) }; } const typed = (sent: ChannelMessage[]) => sent.filter((m) => m instanceof Uint8Array).map((m) => new TextDecoder().decode(m as Uint8Array)); Deno.test({ name: "the view starts a shell on opening, draws what it writes, and sends what is typed", ignore: !existsSync(WASM), fn: () => withDocument(async (window) => { const ghostty = await Ghostty.load(await Deno.readFile(WASM)); const { channel, sent, say } = server(); const matchMedia = window.matchMedia.bind(window); let resolution = matchMedia("(resolution: 1dppx)"); window.matchMedia = (query) => resolution = matchMedia(query); const view = terminalView(() => Promise.resolve(ghostty)); document.body.append(view.element); view.update(viewContext({ connect: () => channel })); say({ t: "none" }); await until(() => sent.length > 0); assertEquals(sent, [{ t: "start", cols: 80, rows: 24 }]); const shell = ghostty.terminal(80, 24); shell.colors(COLORS); say(tagged(SNAPSHOT, shell.snapshot())); say(tagged(OUTPUT, new TextEncoder().encode("$ \x1b[1mbold\x1b[0m\r\n$ "))); const drawn = () => [...view.element.querySelectorAll(".terminal-rows > div")]; await until(() => drawn()[1]?.textContent === "$"); const rows = drawn(); assertEquals(rows.length, 24); assertEquals(rows[0].textContent, "$ bold"); const bold = [...rows[0].children].find((span) => span.textContent === "bold") as HTMLElement; assertEquals(bold.style.fontWeight, "bold"); assertEquals(rows[1].textContent, "$"); // black text on the terminal's own background is drawn so that it reads, as a gray say(tagged(OUTPUT, new TextEncoder().encode("\x1b[30mdark\x1b[0m\r\n"))); const dark = () => [...rows[1].children].find((span) => span.textContent === "dark") as HTMLElement; await until(() => dark() !== undefined); assertMatch(dark().style.color, /^rgb\((\d+) \1 \1\)$/); const keys = view.element.querySelector("textarea")!; const press = (init: { key: string; code: string; ctrlKey?: boolean }) => keys.dispatchEvent( new window.KeyboardEvent("keydown", { bubbles: true, cancelable: true, ...init, }) as unknown as Event, ); press({ key: "l", code: "KeyL" }); press({ key: "c", code: "KeyC", ctrlKey: true }); press({ key: "Enter", code: "Enter" }); assertEquals(typed(sent), ["l", "\x03", "\r"]); const selection = window.getSelection(); const range = window.document.createRange(); range.selectNodeContents( rows[0] as unknown as Parameters[0], ); selection.addRange(range); assertEquals(selection.toString(), "$ bold"); const copied: string[] = []; const copy = new window.Event("copy", { bubbles: true, cancelable: true }); Object.defineProperty(copy, "clipboardData", { value: { setData: (type: string, text: string) => type === "text/plain" && copied.push(text), }, }); rows[0].dispatchEvent(copy as unknown as Event); assertEquals(copy.defaultPrevented, true); assertEquals(copied, ["$ bold"]); assertEquals(press({ key: "c", code: "KeyC", ctrlKey: true }), true); assertEquals(typed(sent), ["l", "\x03", "\r"]); const canvas = view.element.querySelector("canvas")!; const width = canvas.width; const messages = sent.length; Object.defineProperty(window, "devicePixelRatio", { value: 1.25, configurable: true }); resolution.dispatchEvent(new window.Event("change")); await until(() => canvas.width !== width); assertEquals(canvas.width, Math.round(width * 1.25)); assertEquals(selection.toString(), "$ bold"); assertEquals(sent.length, messages); selection.removeAllRanges(); const emptyCopy = new window.Event("copy", { bubbles: true, cancelable: true }); Object.defineProperty(emptyCopy, "clipboardData", { value: { setData: (_type: string, text: string) => copied.push(text) }, }); rows[0].dispatchEvent(emptyCopy as unknown as Event); assertEquals(emptyCopy.defaultPrevented, false); assertEquals(copied, ["$ bold"]); assertEquals(press({ key: "+", code: "Equal", ctrlKey: true }), true); assertEquals(press({ key: "-", code: "Minus", ctrlKey: true }), true); assertEquals(press({ key: "0", code: "Digit0", ctrlKey: true }), true); assertEquals(typed(sent), ["l", "\x03", "\r"]); const wheel = new window.WheelEvent("wheel", { deltaY: 10, cancelable: true }); Object.defineProperty(wheel, "ctrlKey", { value: true }); assertEquals( view.element.querySelector(".terminal-screen")!.dispatchEvent(wheel as unknown as Event), true, ); say({ t: "exit", code: 3 }); const bar = view.element.querySelector(".terminal-status")!; await until(() => !bar.hidden); assertEquals(bar.hidden, false); assertEquals(bar.querySelector("p")!.textContent, "the shell exited with 3"); bar.querySelector("button")!.click(); assertEquals(sent.at(-1), { t: "start", cols: 80, rows: 24 }); view.dispose?.(); }), }); Deno.test({ name: "a viewer that finds no shell after it has seen one offers to start one rather than starting it", ignore: !existsSync(WASM), fn: () => withDocument(async () => { const ghostty = await Ghostty.load(await Deno.readFile(WASM)); const { channel, sent, say } = server(); const view = terminalView(() => Promise.resolve(ghostty)); view.update(viewContext({ connect: () => channel })); say({ t: "exit", code: 0 }); say({ t: "none" }); await until(() => view.element.querySelector("p")!.textContent!.startsWith("no shell")); assertEquals(sent, []); assertEquals(view.element.querySelector("p")!.textContent, "no shell is running here"); view.dispose?.(); }), });