char-slop/berry

multipurpose self-modifiable agentic harness

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

Charlotte Somcleanup: hang up PTYs when their process exits0575637

main
9.2 KiB257 linesraw
1import { assert, assertEquals, assertStringIncludes } from "@std/assert";
2import { existsSync } from "node:fs";
3import type { Channel, ChannelMessage, Environment } from "@berry/api";
4import { localEnvironment } from "@berry/api/environment.ts";
5import { testExtension } from "@berry/api/testing.ts";
6import { tempDir, until } from "@berry/util";
7import { dirname, join } from "node:path";
8import terminal from "./mod.ts";
9import { Ghostty } from "./ghostty.ts";
10import { OUTPUT, SNAPSHOT } from "./protocol.ts";
11
12const WASM = new URL("./ghostty-vt.wasm", import.meta.url);
13const ready = existsSync(WASM) && Deno.build.os === "linux";
14
15/** This machine, with a plain shell rather than whatever logs in here. */
16function plainShell(): Environment {
17  const local = localEnvironment(Deno.cwd());
18  return new Proxy(local, {
19    get(target, property) {
20      if (property === "variables") return { ...target.variables, SHELL: "/bin/sh", ENV: "" };
21      const value = Reflect.get(target, property, target);
22      return typeof value === "function" ? value.bind(target) : value;
23    },
24  });
25}
26
27/** A viewer: everything the channel has said, and a way to wait for what it will. */
28function viewer(channel: Channel) {
29  const said: ChannelMessage[] = [];
30  let heard = () => {};
31  void (async () => {
32    for await (const message of channel.messages) {
33      said.push(message);
34      heard();
35    }
36  })();
37  const output = () =>
38    new TextDecoder().decode(
39      new Uint8Array(
40        said.flatMap((m) => m instanceof Uint8Array && m[0] === OUTPUT ? [...m.subarray(1)] : []),
41      ),
42    );
43  return {
44    said,
45    output,
46    async until(done: () => boolean) {
47      while (!done()) await new Promise<void>((resolve) => heard = resolve);
48    },
49    type: (text: string) => channel.send(new TextEncoder().encode(text)),
50    close: () => channel.close(),
51  };
52}
53
54const isSnapshot = (m: ChannelMessage) => m instanceof Uint8Array && m[0] === SNAPSHOT;
55
56Deno.test({
57  name: "a shell starts when asked, and its screen outlives its viewers until it exits",
58  ignore: !ready,
59  async fn() {
60    const tested = testExtension(terminal, { env: plainShell() });
61    try {
62      const first = viewer(tested.connect("session"));
63      await first.until(() => first.said.length > 0);
64      assertEquals(first.said, [{ t: "none" }]);
65      tested.connect("session").send({ t: "start", cols: 40, rows: 10 });
66      await first.until(() => first.said.some(isSnapshot));
67      first.type("echo marked $((6 * 7))\r");
68      await first.until(() => first.output().includes("marked 42"));
69      first.close();
70
71      // whoever comes later sees the screen so far, scrollback and all
72      const later = viewer(tested.connect("session"));
73      await later.until(() => later.said.some(isSnapshot));
74      const snapshot = later.said.find(isSnapshot) as Uint8Array;
75      const ghostty = await Ghostty.load(await Deno.readFile(WASM));
76      assertStringIncludes(ghostty.restore(snapshot.subarray(1)).text(), "marked 42");
77
78      later.type("exit 3\r");
79      await later.until(() => later.said.some((m) => (m as { t?: string }).t === "exit"));
80      assertEquals(later.said.at(-1), { t: "exit", code: 3 });
81
82      // nothing starts again on its own: the last screen stays, with how it ended
83      const after = viewer(tested.connect("session"));
84      await after.until(() => after.said.length >= 2);
85      assert(isSnapshot(after.said[0]));
86      assertEquals(after.said[1], { t: "exit", code: 3 });
87    } finally {
88      await tested.dispose();
89    }
90  },
91});
92
93Deno.test({
94  name: "a resize reaches the shell and every viewer, and one start is one shell",
95  ignore: !ready,
96  async fn() {
97    const tested = testExtension(terminal, { env: plainShell() });
98    try {
99      const one = viewer(tested.connect("session"));
100      const two = viewer(tested.connect("session"));
101      tested.connect("session").send({ t: "start", cols: 40, rows: 10 });
102      tested.connect("session").send({ t: "start", cols: 40, rows: 10 });
103      await one.until(() => one.said.some(isSnapshot));
104      tested.connect("session").send({ t: "resize", cols: 60, rows: 20 });
105      await two.until(() => two.said.some((m) => (m as { t?: string }).t === "size"));
106      one.type("stty size\r");
107      await one.until(() => one.output().includes("20 60"));
108      assertEquals(one.said.filter(isSnapshot).length, 1);
109    } finally {
110      await tested.dispose();
111    }
112  },
113});
114
115Deno.test({
116  name: "a shell's last output precedes its exit and a replacement shell",
117  ignore: !existsSync(WASM),
118  async fn() {
119    const env = localEnvironment(Deno.cwd());
120    let output!: ReadableStreamDefaultController<Uint8Array>;
121    let starts = 0;
122    env.pty = () =>
123      Promise.resolve({
124        readable: new ReadableStream({
125          start(controller) {
126            if (++starts === 1) output = controller;
127            else controller.close();
128          },
129        }),
130        writable: new WritableStream(),
131        exited: Promise.resolve(0),
132        kill() {},
133        resize() {},
134      });
135    const tested = testExtension(terminal, { env });
136    let ended = false;
137    try {
138      const channel = tested.connect("session");
139      const view = viewer(channel);
140      channel.send({ t: "start", cols: 80, rows: 24 });
141      await until(() => view.said.some(isSnapshot));
142      // output completion, not the former grace timer, must gate replacement
143      await new Promise((resolve) => setTimeout(resolve, 350));
144      assertEquals(view.said.some((m) => (m as { t?: string }).t === "exit"), false);
145      output.enqueue(new TextEncoder().encode("farewell"));
146      output.close();
147      ended = true;
148      await until(() => view.said.some((m) => (m as { t?: string }).t === "exit"));
149      assertStringIncludes(view.output(), "farewell");
150      assertEquals(view.said.at(-1), { t: "exit", code: 0 });
151      channel.send({ t: "start", cols: 80, rows: 24 });
152      await until(() => starts === 2);
153    } finally {
154      if (output && !ended) output.close();
155      await tested.dispose();
156    }
157  },
158});
159
160Deno.test({
161  name: "disposing a terminal drains output after hangup before freeing its screen",
162  ignore: !existsSync(WASM),
163  async fn() {
164    const env = localEnvironment(Deno.cwd());
165    let output!: ReadableStreamDefaultController<Uint8Array>;
166    const exited = Promise.withResolvers<number | null>();
167    env.pty = () =>
168      Promise.resolve({
169        readable: new ReadableStream({ start: (controller) => void (output = controller) }),
170        writable: new WritableStream(),
171        exited: exited.promise,
172        kill() {
173          output.enqueue(new TextEncoder().encode("farewell"));
174          exited.resolve(null);
175        },
176        resize() {},
177      });
178    const tested = testExtension(terminal, { env });
179    let disposing: Promise<void> | undefined;
180    try {
181      const channel = tested.connect("session");
182      const view = viewer(channel);
183      channel.send({ t: "start", cols: 80, rows: 24 });
184      await until(() => view.said.some(isSnapshot));
185      let disposed = false;
186      disposing = tested.dispose().then(() => {
187        disposed = true;
188      });
189      await until(() => view.output().includes("farewell"));
190      assertEquals(disposed, false);
191    } finally {
192      disposing ??= tested.dispose();
193      output?.close();
194      await disposing;
195    }
196  },
197});
198
199Deno.test({
200  name: "Windows terminals launch an interactive PowerShell on the target, preferring PS7 to PS5",
201  ignore: !existsSync(WASM),
202  async fn() {
203    await using root = await tempDir();
204    const binaries = [
205      "bin dir/pwsh.EXE",
206      "program files/PowerShell/7/pwsh.exe",
207      "legacy/powershell.EXE",
208      "windows/System32/WindowsPowerShell/v1.0/powershell.exe",
209    ].map((path) => join(root.path, path));
210    for (const path of binaries) {
211      await Deno.mkdir(dirname(path), { recursive: true });
212      await Deno.writeTextFile(path, "");
213    }
214    const invocations: (readonly string[])[] = [];
215    const env = localEnvironment(root.path);
216    Object.defineProperties(env, {
217      platform: { value: "win32" },
218      variables: {
219        value: {
220          pAtH: `"${dirname(binaries[0])}";${dirname(binaries[2])}`,
221          PaThExT: ".EXE",
222          ProgramFiles: join(root.path, "program files"),
223          SystemRoot: join(root.path, "windows"),
224          SHELL: "/not/a/windows/shell",
225        },
226      },
227    });
228    env.pty = (argv) => {
229      invocations.push(argv);
230      return Promise.resolve({
231        readable: new ReadableStream({ start: (controller) => controller.close() }),
232        writable: new WritableStream(),
233        exited: Promise.resolve(0),
234        kill() {},
235        resize() {},
236      });
237    };
238    const tested = testExtension(terminal, { env });
239    try {
240      const channel = tested.connect("session");
241      const view = viewer(channel);
242      await until(() => view.said.length > 0);
243      for (const binary of binaries) {
244        view.said.length = 0;
245        channel.send({ t: "start", cols: 80, rows: 24 });
246        await until(() => view.said.some((m) => (m as { t?: string }).t === "exit"));
247        assertEquals(invocations.at(-1), [binary, "-NoLogo"]);
248        await Deno.remove(binary);
249      }
250      channel.send({ t: "start", cols: 80, rows: 24 });
251      await until(() => view.said.some((m) => (m as { t?: string }).t === "error"));
252      assertEquals(invocations.length, 4);
253    } finally {
254      await tested.dispose();
255    }
256  },
257});