char-slop/berry

multipurpose self-modifiable agentic harness

git clone https://git.t4t.associates/char-slop/berry

Charlotte Somdirectly copy selection to clipboard to work around firefox bug871f159

main
7.2 KiB163 linesraw
1import { assertEquals, assertMatch } from "@std/assert";
2import { existsSync } from "node:fs";
3import type { Channel, ChannelMessage } from "@berry/api/web.ts";
4import type { Window } from "happy-dom";
5import { until } from "@berry/util";
6import { viewContext, withDom } from "@berry/util/dom.ts";
7import { Ghostty } from "./ghostty.ts";
8import { COLORS } from "./palette.ts";
9import { OUTPUT, SNAPSHOT, tagged } from "./protocol.ts";
10import { terminalView } from "./view.tsx";
11
12const WASM = new URL("./ghostty-vt.wasm", import.meta.url);
13
14const withDocument = (test: (window: Window) => Promise<void>) =>
15  withDom(test, {
16    globals: {
17      requestAnimationFrame: (callback: () => void) => setTimeout(callback, 0),
18      cancelAnimationFrame: (handle: number) => clearTimeout(handle),
19    },
20  });
21
22/** The server's end, played by the test: what the view sent, and a way to answer. */
23function server() {
24  const sent: ChannelMessage[] = [];
25  let say!: ReadableStreamDefaultController<ChannelMessage>;
26  const channel: Channel = {
27    send: (message) => void sent.push(message),
28    messages: new ReadableStream({ start: (controller) => void (say = controller) }),
29    close: () => {},
30  };
31  return { channel, sent, say: (message: ChannelMessage) => say.enqueue(message) };
32}
33
34const typed = (sent: ChannelMessage[]) =>
35  sent.filter((m) => m instanceof Uint8Array).map((m) => new TextDecoder().decode(m as Uint8Array));
36
37Deno.test({
38  name: "the view starts a shell on opening, draws what it writes, and sends what is typed",
39  ignore: !existsSync(WASM),
40  fn: () =>
41    withDocument(async (window) => {
42      const ghostty = await Ghostty.load(await Deno.readFile(WASM));
43      const { channel, sent, say } = server();
44      const matchMedia = window.matchMedia.bind(window);
45      let resolution = matchMedia("(resolution: 1dppx)");
46      window.matchMedia = (query) => resolution = matchMedia(query);
47      const view = terminalView(() => Promise.resolve(ghostty));
48      document.body.append(view.element);
49      view.update(viewContext({ connect: () => channel }));
50      say({ t: "none" });
51      await until(() => sent.length > 0);
52      assertEquals(sent, [{ t: "start", cols: 80, rows: 24 }]);
53
54      const shell = ghostty.terminal(80, 24);
55      shell.colors(COLORS);
56      say(tagged(SNAPSHOT, shell.snapshot()));
57      say(tagged(OUTPUT, new TextEncoder().encode("$ \x1b[1mbold\x1b[0m\r\n$ ")));
58      const drawn = () => [...view.element.querySelectorAll(".terminal-rows > div")];
59      await until(() => drawn()[1]?.textContent === "$");
60      const rows = drawn();
61      assertEquals(rows.length, 24);
62      assertEquals(rows[0].textContent, "$ bold");
63      const bold = [...rows[0].children].find((span) => span.textContent === "bold") as HTMLElement;
64      assertEquals(bold.style.fontWeight, "bold");
65      assertEquals(rows[1].textContent, "$");
66
67      // black text on the terminal's own background is drawn so that it reads, as a gray
68      say(tagged(OUTPUT, new TextEncoder().encode("\x1b[30mdark\x1b[0m\r\n")));
69      const dark = () =>
70        [...rows[1].children].find((span) => span.textContent === "dark") as HTMLElement;
71      await until(() => dark() !== undefined);
72      assertMatch(dark().style.color, /^rgb\((\d+) \1 \1\)$/);
73
74      const keys = view.element.querySelector("textarea")!;
75      const press = (init: { key: string; code: string; ctrlKey?: boolean }) =>
76        keys.dispatchEvent(
77          new window.KeyboardEvent("keydown", {
78            bubbles: true,
79            cancelable: true,
80            ...init,
81          }) as unknown as Event,
82        );
83      press({ key: "l", code: "KeyL" });
84      press({ key: "c", code: "KeyC", ctrlKey: true });
85      press({ key: "Enter", code: "Enter" });
86      assertEquals(typed(sent), ["l", "\x03", "\r"]);
87
88      const selection = window.getSelection();
89      const range = window.document.createRange();
90      range.selectNodeContents(
91        rows[0] as unknown as Parameters<typeof range.selectNodeContents>[0],
92      );
93      selection.addRange(range);
94      assertEquals(selection.toString(), "$ bold");
95      const copied: string[] = [];
96      const copy = new window.Event("copy", { bubbles: true, cancelable: true });
97      Object.defineProperty(copy, "clipboardData", {
98        value: {
99          setData: (type: string, text: string) => type === "text/plain" && copied.push(text),
100        },
101      });
102      rows[0].dispatchEvent(copy as unknown as Event);
103      assertEquals(copy.defaultPrevented, true);
104      assertEquals(copied, ["$ bold"]);
105      assertEquals(press({ key: "c", code: "KeyC", ctrlKey: true }), true);
106      assertEquals(typed(sent), ["l", "\x03", "\r"]);
107      const canvas = view.element.querySelector("canvas")!;
108      const width = canvas.width;
109      const messages = sent.length;
110      Object.defineProperty(window, "devicePixelRatio", { value: 1.25, configurable: true });
111      resolution.dispatchEvent(new window.Event("change"));
112      await until(() => canvas.width !== width);
113      assertEquals(canvas.width, Math.round(width * 1.25));
114      assertEquals(selection.toString(), "$ bold");
115      assertEquals(sent.length, messages);
116      selection.removeAllRanges();
117      const emptyCopy = new window.Event("copy", { bubbles: true, cancelable: true });
118      Object.defineProperty(emptyCopy, "clipboardData", {
119        value: { setData: (_type: string, text: string) => copied.push(text) },
120      });
121      rows[0].dispatchEvent(emptyCopy as unknown as Event);
122      assertEquals(emptyCopy.defaultPrevented, false);
123      assertEquals(copied, ["$ bold"]);
124      assertEquals(press({ key: "+", code: "Equal", ctrlKey: true }), true);
125      assertEquals(press({ key: "-", code: "Minus", ctrlKey: true }), true);
126      assertEquals(press({ key: "0", code: "Digit0", ctrlKey: true }), true);
127      assertEquals(typed(sent), ["l", "\x03", "\r"]);
128      const wheel = new window.WheelEvent("wheel", { deltaY: 10, cancelable: true });
129      Object.defineProperty(wheel, "ctrlKey", { value: true });
130      assertEquals(
131        view.element.querySelector(".terminal-screen")!.dispatchEvent(wheel as unknown as Event),
132        true,
133      );
134
135      say({ t: "exit", code: 3 });
136      const bar = view.element.querySelector<HTMLElement>(".terminal-status")!;
137      await until(() => !bar.hidden);
138      assertEquals(bar.hidden, false);
139      assertEquals(bar.querySelector("p")!.textContent, "the shell exited with 3");
140      bar.querySelector("button")!.click();
141      assertEquals(sent.at(-1), { t: "start", cols: 80, rows: 24 });
142      view.dispose?.();
143    }),
144});
145
146Deno.test({
147  name:
148    "a viewer that finds no shell after it has seen one offers to start one rather than starting it",
149  ignore: !existsSync(WASM),
150  fn: () =>
151    withDocument(async () => {
152      const ghostty = await Ghostty.load(await Deno.readFile(WASM));
153      const { channel, sent, say } = server();
154      const view = terminalView(() => Promise.resolve(ghostty));
155      view.update(viewContext({ connect: () => channel }));
156      say({ t: "exit", code: 0 });
157      say({ t: "none" });
158      await until(() => view.element.querySelector("p")!.textContent!.startsWith("no shell"));
159      assertEquals(sent, []);
160      assertEquals(view.element.querySelector("p")!.textContent, "no shell is running here");
161      view.dispose?.();
162    }),
163});