import { assertEquals, assertMatch, assertNotEquals, assertNotMatch, assertRejects, } from "@std/assert"; import { existsSync } from "node:fs"; import { tempDir, until as eventually } from "@berry/util"; import { BACKGROUND_CONTEXT as context, withCancel } from "@earendil-works/chord/context"; import { type Environment, localEnvironment, type Pty } from "@berry/api/environment.ts"; import { BOOTSTRAP, Connection, RemoteEnvironment } from "./remote/connection.ts"; /** What the program has written so far, read until `pattern` turns up in it. */ async function until( reader: ReadableStreamDefaultReader, seen: string[], pattern: RegExp, ) { const decoder = new TextDecoder(); while (!pattern.test(seen.join(""))) { const { value, done } = await reader.read(); if (done) throw new Error(`ended before ${pattern}: ${JSON.stringify(seen.join(""))}`); seen.push(decoder.decode(value, { stream: true })); } return seen.join(""); } const SCRIPT = [ "tty", "stty size", "read line", 'echo "got $line"', "stty size", 'trap "echo interrupted; exit 5" INT', "echo trapped", "sleep 30 & wait", ].join("; "); async function converse(env: Environment) { const pty: Pty = await env.pty(["sh", "-c", SCRIPT], { cols: 80, rows: 24 }, context); const reader = pty.readable.getReader(); const writer = pty.writable.getWriter(); const seen: string[] = []; assertMatch(await until(reader, seen, /24 80/), /^\/dev\/pts\/\d+\r\n/); pty.resize(100, 30); // the terminal turns a typed return into the newline `read` waits for await writer.write(new TextEncoder().encode("hello\r")); await until(reader, seen, /got hello\r\n30 100\r\ntrapped\r\n/); // ctrl-c reaches the program as a signal, which only a terminal of its own makes so await writer.write(new TextEncoder().encode("\x03")); await until(reader, seen, /interrupted/); assertEquals(await pty.exited, 5); for (let next = await reader.read(); !next.done; next = await reader.read()); } /** A program that leaves something holding its terminal, to write there after it has exited. */ async function leaves(env: Environment) { await using dir = await tempDir(); const status = `${dir.path}/status`; const pty = await env.pty( [ "sh", "-c", // quoted apart, so the leftover's words are not the command's `trap "" HUP; (sleep 1; echo left""over; echo $? > ${status}) & echo started`, ], { cols: 80, rows: 24 }, context, ); const output = await new Response(pty.readable).text(); assertMatch(output, /started/); assertNotMatch(output, /leftover/); assertEquals(await pty.exited, 0); // the terminal hung up under it await eventually(() => existsSync(status)); assertNotEquals((await Deno.readTextFile(status)).trim(), "0"); } Deno.test({ name: "a program on a pty has a terminal of its own: sized, resized, typed into and interrupted", ignore: Deno.build.os !== "linux", fn: () => converse(localEnvironment(Deno.cwd())), }); Deno.test({ name: "a pty hangs up once its program exits, whatever that left holding it", ignore: Deno.build.os !== "linux", fn: () => leaves(localEnvironment(Deno.cwd())), }); Deno.test({ name: "a pty hangs up when it is killed", ignore: Deno.build.os !== "linux", async fn() { const pty = await localEnvironment(Deno.cwd()).pty(["sh", "-c", "sleep 30"], { cols: 80, rows: 24, }, context); pty.kill(); assertEquals(await pty.exited, null); await pty.readable.pipeTo(new WritableStream()); }, }); Deno.test({ name: "aborting a pty wakes blocked readers and writers", ignore: Deno.build.os !== "linux", async fn() { const cancelled = withCancel(context); const pty = await localEnvironment(Deno.cwd()).pty( ["sh", "-c", "stty raw -echo; echo ready; exec sleep 30"], { cols: 80, rows: 24 }, cancelled.context, ); const reader = pty.readable.getReader(); const writer = pty.writable.getWriter(); try { await until(reader, [], /ready\n/); let readEnded = false; let writeEnded = false; const reading = reader.read().then((next) => { readEnded = true; return next; }); const writing = writer.write(new Uint8Array(1024 * 1024).fill(97)); const rejected = assertRejects(() => writing, Error).then(() => writeEnded = true); await new Promise((resolve) => setTimeout(resolve, 20)); assertEquals([readEnded, writeEnded], [false, false]); cancelled.cancel(); await eventually(() => readEnded && writeEnded); assertEquals((await reading).done, true); await rejected; assertEquals(await pty.exited, null); } finally { pty.kill(); await reader.cancel(); await pty.exited; } }, }); Deno.test({ name: "a pty transfers input larger than its buffer without losing bytes", ignore: Deno.build.os !== "linux", async fn() { const pty = await localEnvironment(Deno.cwd()).pty( ["sh", "-c", "stty raw -echo; echo ready; exec cat"], { cols: 80, rows: 24 }, context, ); const reader = pty.readable.getReader(); const writer = pty.writable.getWriter(); try { await until(reader, [], /ready\n/); const input = new Uint8Array(256 * 1024); for (let i = 0; i < input.length; i++) input[i] = i % 256; const output = new Uint8Array(input.length); await Promise.all([ writer.write(input), (async () => { for (let at = 0; at < output.length;) { const { value, done } = await reader.read(); if (done) throw new Error("Terminal ended before echoing its input"); output.set(value, at); at += value.length; } })(), ]); assertEquals(output, input); } finally { pty.kill(); await reader.cancel(); await pty.exited; } }, }); Deno.test({ name: "a pty starts its program where it is asked, with the variables it is given, or says why not", ignore: Deno.build.os !== "linux", async fn() { const env = localEnvironment("/"); const pty = await env.pty(["sh", "-c", 'echo "$PWD $THERE"'], { cwd: "/tmp", env: { THERE: "here" }, cols: 80, rows: 24, }, context); assertEquals(await new Response(pty.readable).text(), "/tmp here\r\n"); assertEquals(await pty.exited, 0); await assertRejects( () => env.pty(["berry-no-such-program"], { cols: 80, rows: 24 }, context), Error, "No such program", ); }, }); Deno.test({ name: "a pty on another machine speaks, resizes, and hangs up through its worker", ignore: Deno.build.os !== "linux", async fn() { const connection = await Connection.open("far", [ Deno.execPath(), "eval", "--quiet", BOOTSTRAP, ]); try { await converse(new RemoteEnvironment("far", connection, "/")); await leaves(new RemoteEnvironment("far", connection, "/")); } finally { await connection.close(); } }, });