import { assert, assertEquals, assertStringIncludes } from "@std/assert"; import { existsSync } from "node:fs"; import type { Channel, ChannelMessage, Environment } from "@berry/api"; import { localEnvironment } from "@berry/api/environment.ts"; import { testExtension } from "@berry/api/testing.ts"; import { tempDir, until } from "@berry/util"; import { dirname, join } from "node:path"; import terminal from "./mod.ts"; import { Ghostty } from "./ghostty.ts"; import { OUTPUT, SNAPSHOT } from "./protocol.ts"; const WASM = new URL("./ghostty-vt.wasm", import.meta.url); const ready = existsSync(WASM) && Deno.build.os === "linux"; /** This machine, with a plain shell rather than whatever logs in here. */ function plainShell(): Environment { const local = localEnvironment(Deno.cwd()); return new Proxy(local, { get(target, property) { if (property === "variables") return { ...target.variables, SHELL: "/bin/sh", ENV: "" }; const value = Reflect.get(target, property, target); return typeof value === "function" ? value.bind(target) : value; }, }); } /** A viewer: everything the channel has said, and a way to wait for what it will. */ function viewer(channel: Channel) { const said: ChannelMessage[] = []; let heard = () => {}; void (async () => { for await (const message of channel.messages) { said.push(message); heard(); } })(); const output = () => new TextDecoder().decode( new Uint8Array( said.flatMap((m) => m instanceof Uint8Array && m[0] === OUTPUT ? [...m.subarray(1)] : []), ), ); return { said, output, async until(done: () => boolean) { while (!done()) await new Promise((resolve) => heard = resolve); }, type: (text: string) => channel.send(new TextEncoder().encode(text)), close: () => channel.close(), }; } const isSnapshot = (m: ChannelMessage) => m instanceof Uint8Array && m[0] === SNAPSHOT; Deno.test({ name: "a shell starts when asked, and its screen outlives its viewers until it exits", ignore: !ready, async fn() { const tested = testExtension(terminal, { env: plainShell() }); try { const first = viewer(tested.connect("session")); await first.until(() => first.said.length > 0); assertEquals(first.said, [{ t: "none" }]); tested.connect("session").send({ t: "start", cols: 40, rows: 10 }); await first.until(() => first.said.some(isSnapshot)); first.type("echo marked $((6 * 7))\r"); await first.until(() => first.output().includes("marked 42")); first.close(); // whoever comes later sees the screen so far, scrollback and all const later = viewer(tested.connect("session")); await later.until(() => later.said.some(isSnapshot)); const snapshot = later.said.find(isSnapshot) as Uint8Array; const ghostty = await Ghostty.load(await Deno.readFile(WASM)); assertStringIncludes(ghostty.restore(snapshot.subarray(1)).text(), "marked 42"); later.type("exit 3\r"); await later.until(() => later.said.some((m) => (m as { t?: string }).t === "exit")); assertEquals(later.said.at(-1), { t: "exit", code: 3 }); // nothing starts again on its own: the last screen stays, with how it ended const after = viewer(tested.connect("session")); await after.until(() => after.said.length >= 2); assert(isSnapshot(after.said[0])); assertEquals(after.said[1], { t: "exit", code: 3 }); } finally { await tested.dispose(); } }, }); Deno.test({ name: "a resize reaches the shell and every viewer, and one start is one shell", ignore: !ready, async fn() { const tested = testExtension(terminal, { env: plainShell() }); try { const one = viewer(tested.connect("session")); const two = viewer(tested.connect("session")); tested.connect("session").send({ t: "start", cols: 40, rows: 10 }); tested.connect("session").send({ t: "start", cols: 40, rows: 10 }); await one.until(() => one.said.some(isSnapshot)); tested.connect("session").send({ t: "resize", cols: 60, rows: 20 }); await two.until(() => two.said.some((m) => (m as { t?: string }).t === "size")); one.type("stty size\r"); await one.until(() => one.output().includes("20 60")); assertEquals(one.said.filter(isSnapshot).length, 1); } finally { await tested.dispose(); } }, }); Deno.test({ name: "a shell's last output precedes its exit and a replacement shell", ignore: !existsSync(WASM), async fn() { const env = localEnvironment(Deno.cwd()); let output!: ReadableStreamDefaultController; let starts = 0; env.pty = () => Promise.resolve({ readable: new ReadableStream({ start(controller) { if (++starts === 1) output = controller; else controller.close(); }, }), writable: new WritableStream(), exited: Promise.resolve(0), kill() {}, resize() {}, }); const tested = testExtension(terminal, { env }); let ended = false; try { const channel = tested.connect("session"); const view = viewer(channel); channel.send({ t: "start", cols: 80, rows: 24 }); await until(() => view.said.some(isSnapshot)); // output completion, not the former grace timer, must gate replacement await new Promise((resolve) => setTimeout(resolve, 350)); assertEquals(view.said.some((m) => (m as { t?: string }).t === "exit"), false); output.enqueue(new TextEncoder().encode("farewell")); output.close(); ended = true; await until(() => view.said.some((m) => (m as { t?: string }).t === "exit")); assertStringIncludes(view.output(), "farewell"); assertEquals(view.said.at(-1), { t: "exit", code: 0 }); channel.send({ t: "start", cols: 80, rows: 24 }); await until(() => starts === 2); } finally { if (output && !ended) output.close(); await tested.dispose(); } }, }); Deno.test({ name: "disposing a terminal drains output after hangup before freeing its screen", ignore: !existsSync(WASM), async fn() { const env = localEnvironment(Deno.cwd()); let output!: ReadableStreamDefaultController; const exited = Promise.withResolvers(); env.pty = () => Promise.resolve({ readable: new ReadableStream({ start: (controller) => void (output = controller) }), writable: new WritableStream(), exited: exited.promise, kill() { output.enqueue(new TextEncoder().encode("farewell")); exited.resolve(null); }, resize() {}, }); const tested = testExtension(terminal, { env }); let disposing: Promise | undefined; try { const channel = tested.connect("session"); const view = viewer(channel); channel.send({ t: "start", cols: 80, rows: 24 }); await until(() => view.said.some(isSnapshot)); let disposed = false; disposing = tested.dispose().then(() => { disposed = true; }); await until(() => view.output().includes("farewell")); assertEquals(disposed, false); } finally { disposing ??= tested.dispose(); output?.close(); await disposing; } }, }); Deno.test({ name: "Windows terminals launch an interactive PowerShell on the target, preferring PS7 to PS5", ignore: !existsSync(WASM), async fn() { await using root = await tempDir(); const binaries = [ "bin dir/pwsh.EXE", "program files/PowerShell/7/pwsh.exe", "legacy/powershell.EXE", "windows/System32/WindowsPowerShell/v1.0/powershell.exe", ].map((path) => join(root.path, path)); for (const path of binaries) { await Deno.mkdir(dirname(path), { recursive: true }); await Deno.writeTextFile(path, ""); } const invocations: (readonly string[])[] = []; const env = localEnvironment(root.path); Object.defineProperties(env, { platform: { value: "win32" }, variables: { value: { pAtH: `"${dirname(binaries[0])}";${dirname(binaries[2])}`, PaThExT: ".EXE", ProgramFiles: join(root.path, "program files"), SystemRoot: join(root.path, "windows"), SHELL: "/not/a/windows/shell", }, }, }); env.pty = (argv) => { invocations.push(argv); return Promise.resolve({ readable: new ReadableStream({ start: (controller) => controller.close() }), writable: new WritableStream(), exited: Promise.resolve(0), kill() {}, resize() {}, }); }; const tested = testExtension(terminal, { env }); try { const channel = tested.connect("session"); const view = viewer(channel); await until(() => view.said.length > 0); for (const binary of binaries) { view.said.length = 0; channel.send({ t: "start", cols: 80, rows: 24 }); await until(() => view.said.some((m) => (m as { t?: string }).t === "exit")); assertEquals(invocations.at(-1), [binary, "-NoLogo"]); await Deno.remove(binary); } channel.send({ t: "start", cols: 80, rows: 24 }); await until(() => view.said.some((m) => (m as { t?: string }).t === "error")); assertEquals(invocations.length, 4); } finally { await tested.dispose(); } }, });