// Processes on a kernel pty. The line discipline is Linux's n_tty, which no macOS // terminal reproduces, so most of these assert behaviour: what the guest sees of its // terminal, the discipline between keystrokes and stdin, signals from the keyboard, // resizes. The two readline tests in raw mode are byte-exact against Node v24.16 on a // macOS pty, where raw mode leaves nothing to the discipline but ONLCR: // (sleep 1; printf 'keys'; ...) | script -q /dev/null node guest.cjs import { test, expect } from "@playwright/test"; import { runPtyGuest } from "./guest"; test("a pty process sees a terminal: isTTY, size, isatty, TERM, colour depth, tty classes, styled text", async ({ page }) => { const res = await runPtyGuest(page, ` const tty = require("node:tty"); const o = process.stdout; const code = (fn) => { try { fn(); return "ok"; } catch (e) { return e.code; } }; console.log(JSON.stringify([ process.stdin.isTTY, process.stdin.isRaw, o.isTTY, process.stderr.isTTY, [0, 1, 2, 3].map(tty.isatty), o.columns, o.rows, o.getWindowSize(), process.env.TERM, process.env.COLORTERM, o.getColorDepth(), o.hasColors(), o.hasColors(2 ** 24), o.hasColors(2 ** 25), o.getColorDepth({ TERM: "dumb" }), process.stdin instanceof tty.ReadStream, o instanceof tty.WriteStream, process.listenerCount("SIGWINCH"), code(() => new tty.WriteStream(5)), code(() => new tty.ReadStream(0)), new tty.WriteStream(2).columns, ])); console.log({ a: 1 }, "s", JSON.stringify(require("node:util").styleText("red", "x"))); `, [], { size: [100, 30] }); expect(res).toEqual({ code: 0, signal: null, stderr: "", output: `${JSON.stringify([ true, false, true, true, [true, true, true, false], 100, 30, [100, 30], "xterm-256color", "truecolor", 24, true, true, false, 1, true, true, 2, "ERR_TTY_INIT_FAILED", "ERR_NIB_UNSUPPORTED", 100, ])}\r\n{ a: \x1b[33m1\x1b[39m } s "\\u001b[31mx\\u001b[39m"\r\n`, }); }); test("the caller's TERM wins, and console stops colouring when the terminal has none", async ({ page }) => { const res = await runPtyGuest(page, ` console.log(process.env.TERM, process.env.COLORTERM, process.stdout.getColorDepth(), { a: 1 }); `, [], { env: { TERM: "dumb" } }); expect(res.output).toBe("dumb truecolor 1 { a: 1 }\r\n"); }); test("cooked mode: echo, line editing, a line at a time, ^D is EOF", async ({ page }) => { const res = await runPtyGuest(page, ` process.stdin.setEncoding("utf8"); process.stdin.on("data", (d) => console.log("data " + JSON.stringify(d))); process.stdin.on("end", () => console.log("end")); console.log("ready"); `, [ { waitFor: "ready\r\n", write: "hel\x7flo wor\x17x\r" }, { waitFor: "data", write: "\x04" }, ]); expect(res).toEqual({ code: 0, signal: null, stderr: "", output: "ready\r\nhel\b \blo wor\b \b\b \b\b \bx\r\ndata \"helo x\\n\"\r\nend\r\n", }); }); test("raw mode: keystrokes arrive as typed, unechoed, and ^C is a byte; setRawMode(false) restores cooked", async ({ page }) => { const res = await runPtyGuest(page, ` process.stdin.setRawMode(true); process.stdin.on("data", (d) => { const s = d.toString(); console.log("raw " + JSON.stringify(s) + " " + process.stdin.isRaw); if (s === "\\r") { process.stdin.setRawMode(false); console.log("cooked " + process.stdin.isRaw); } if (s === "z\\n") process.stdin.pause(); }); console.log("ready"); `, [ { waitFor: "ready\r\n", write: "\x1b[A" }, { waitFor: "raw", write: "\x03" }, { waitFor: "raw", write: "\r" }, { waitFor: "cooked false\r\n", write: "z\r" }, ]); expect(res).toEqual({ code: 0, signal: null, stderr: "", output: "ready\r\nraw \"\\u001b[A\" true\r\nraw \"\\u0003\" true\r\nraw \"\\r\" true\r\ncooked false\r\nz\r\nraw \"z\\n\" false\r\n", }); }); test("an unfinished line becomes readable when raw mode starts", async ({ page }) => { const res = await runPtyGuest(page, ` process.stdin.on("data", (d) => { console.log("got " + JSON.stringify(d.toString())); process.stdin.pause(); }); setTimeout(() => process.stdin.setRawMode(true), 300); `, [{ write: "ab" }]); expect(res.output).toBe("abgot \"ab\"\r\n"); }); test("resize: SIGWINCH refreshes columns/rows and emits 'resize' only when the size changes", async ({ page }) => { const res = await runPtyGuest(page, ` process.stdout.on("resize", () => console.log("resize " + process.stdout.columns + "x" + process.stdout.rows)); process.stderr.on("resize", () => console.log("stderr " + process.stderr.columns)); process.stdin.on("data", () => process.exit(0)); console.log("ready"); `, [ { waitFor: "ready\r\n", resize: [120, 40] }, { waitFor: "stderr 120\r\n", resize: [120, 40] }, { resize: [90, 20] }, { waitFor: "stderr 90\r\n", write: "x\r" }, ]); expect(res).toEqual({ code: 0, signal: null, stderr: "", output: "ready\r\nresize 120x40\r\nstderr 120\r\nresize 90x20\r\nstderr 90\r\nx\r\n", }); }); test("^C raises SIGINT: a listener takes it, without one the process dies of it", async ({ page }) => { const guest = (listen: boolean) => ` ${listen ? `process.on("SIGINT", (sig) => { console.log("caught " + sig); process.exit(3); });` : ""} process.stdin.resume(); console.log("ready"); `; const steps = [{ waitFor: "ready\r\n", write: "ab\x03" }]; expect(await runPtyGuest(page, guest(true), steps)).toEqual({ code: 3, signal: null, stderr: "", output: "ready\r\nab^Ccaught SIGINT\r\n" }); expect(await runPtyGuest(page, guest(false), steps)).toEqual({ code: 130, signal: "SIGINT", stderr: "", output: "ready\r\nab^C" }); expect(await runPtyGuest(page, guest(false), [{ waitFor: "ready\r\n", write: "\x1c" }])) .toEqual({ code: 131, signal: "SIGQUIT", stderr: "", output: "ready\r\n^\\" }); }); test("stdout and stderr share the terminal, with NL output as CR NL", async ({ page }) => { const res = await runPtyGuest(page, `process.stderr.write("err\\n"); process.stdout.write("a\\nb\\n");`, []); expect(res).toEqual({ code: 0, signal: null, stderr: "", output: "err\r\na\r\nb\r\n" }); }); test("host: pty sizes are whole cells, checked before the process starts", async ({ page }) => { await page.goto("/demo/fixture.html"); await page.waitForFunction(() => window.__runtime !== undefined, undefined, { timeout: 20_000 }); const errors = await page.evaluate(async () => { const runtime = window.__runtime!; runtime.writeTree({ "/app/hold.cjs": "process.stdin.resume();" }); const out: string[] = []; const tryIt = (fn: () => void): void => { try { fn(); out.push("ok"); } catch (e) { out.push(`${(e as Error).name}: ${(e as Error).message}`); } }; tryIt(() => runtime.exec("/app/hold.cjs", { pty: { cols: 0, rows: 24 } })); const proc = runtime.exec("/app/hold.cjs", { pty: { cols: 80, rows: 24 } }); tryIt(() => proc.pty.resize(80.5, 24)); proc.kill("SIGKILL"); out.push(String(await proc.exited)); return out; }); expect(errors).toEqual([ "RangeError: pty size must be whole cells in 1..65535, got 0x24", "RangeError: pty size must be whole cells in 1..65535, got 80.5x24", "137", ]); }); test("terminals started together all start: a sync call before the kernel's first heartbeat is not a stall", async ({ page }) => { await page.goto("/demo/fixture.html"); await page.waitForFunction(() => window.__runtime !== undefined, undefined, { timeout: 20_000 }); const outputs = await page.evaluate(async () => { const runtime = window.__runtime!; runtime.writeTree({ "/app/cols.cjs": "process.stdout.write(process.stdout.columns + '\\n');" }); return await Promise.all(Array.from({ length: 24 }, async (_, i) => { const proc = runtime.exec("/app/cols.cjs", { pty: { cols: 40 + i, rows: 10 } }); const text = await new Response(proc.stdout).text(); return `${await proc.exited} ${text}`; })); }); expect(outputs).toEqual(Array.from({ length: 24 }, (_, i) => `0 ${40 + i}\r\n`)); }); test("raw-mode keypresses through readline: named keys, a lone ESC after its timeout, ^C as a key", async ({ page }) => { const res = await runPtyGuest(page, ` const readline = require("node:readline"); readline.emitKeypressEvents(process.stdin); process.stdin.setRawMode(true); process.stdin.on("keypress", (s, k) => { console.log("key " + k.name + " " + k.ctrl + " " + k.shift); if (k.ctrl && k.name === "c") process.exit(0); }); console.log("ready"); `, [ { waitFor: "ready\r\n", write: "\x1b[A\x1b[B\r\x7f\tQ\x1b[Z" }, { waitFor: "key tab false true\r\n", write: "\x1b" }, { waitFor: "key escape false false\r\n", write: "\x03" }, ]); expect(res).toEqual({ code: 0, signal: null, stderr: "", output: "ready\r\nkey up false false\r\nkey down false false\r\nkey return false false\r\nkey backspace false false\r\n" + "key tab false false\r\nkey q false true\r\nkey tab false true\r\nkey escape false false\r\nkey c true false\r\n", }); }); test("readline on a terminal: raw mode while it reads, its line editor, ^C closes it", async ({ page }) => { const res = await runPtyGuest(page, ` const readline = require("node:readline"); const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); rl.on("close", () => console.log("closed " + process.stdin.isRaw)); rl.question("name? ", (a) => { console.log("hi " + a + " " + rl.terminal + " " + process.stdin.isRaw); rl.question("again? ", () => {}); }); `, [ { waitFor: "name? ", write: "Ad\x7fda\r" }, { waitFor: "again? ", write: "x\x03" }, ]); expect(res).toEqual({ code: 0, signal: null, stderr: "", output: "\x1b[1G\x1b[0Jname? \x1b[7GAd\x1b[1G\x1b[0Jname? A\x1b[8Gda\r\r\nhi Ada true true\r\n" + "\x1b[1G\x1b[0Jagain? \x1b[8Gxclosed false\r\n", }); }); test("^Z in readline: with no job control the process is continued at once, and readline resumes raw mode paused, as after fg", async ({ page }) => { const res = await runPtyGuest(page, ` const readline = require("node:readline"); const rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: "" }); rl.on("SIGCONT", () => setImmediate(() => { console.log("continued " + process.stdin.isRaw + " " + rl.paused + " " + process.listenerCount("SIGCONT")); rl.close(); })); console.log("ready"); `, [{ waitFor: "ready\r\n", write: "\x1a" }]); // After 'SIGCONT' readline takes raw mode back and redraws its (empty) line. expect(res.output).toBe("ready\r\n\x1b[1G\x1b[0J \x1b[1Gcontinued true true 0\r\n"); });