Files
2026-08-27 21:09:14 +00:00

304 lines
10 KiB
TypeScript

import { describe, expect, it, test } from "bun:test";
import { bunEnv, bunExe, isWindows } from "harness";
import { WriteStream } from "node:tty";
describe("ReadStream.prototype.setRawMode", () => {
// Regression: on Windows, the `fd === 0` branch returned early on success
// without ever reaching `this.isRaw = flag`, so `process.stdin.isRaw` stayed
// `false` after a successful `setRawMode(true)`. On POSIX this already
// worked; the test runs on both to lock the behaviour in.
test("updates isRaw on process.stdin after a successful call", async () => {
let output = "";
const decoder = new TextDecoder();
const done = Promise.withResolvers<void>();
const eof = Promise.withResolvers<void>();
const proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
let err;
process.stdin.on("error", e => (err = String(e)));
const isTTY = process.stdin.isTTY;
const before = process.stdin.isRaw;
const ret = process.stdin.setRawMode(true);
const afterTrue = process.stdin.isRaw;
process.stdin.setRawMode(false);
const afterFalse = process.stdin.isRaw;
process.stdout.write(
"RESULT " +
JSON.stringify({
isTTY,
before,
afterTrue,
afterFalse,
returnsThis: ret === process.stdin,
...(err ? { err } : {}),
}),
);
process.exit(0);
`,
],
env: bunEnv,
terminal: {
// Wide enough that ConPTY does not hard-wrap the RESULT line.
cols: 200,
rows: 24,
data(_t, chunk: Uint8Array) {
output += decoder.decode(chunk, { stream: true });
if (output.includes("RESULT ") && output.includes("}")) done.resolve();
},
exit() {
eof.resolve();
},
},
});
await Promise.race([done.promise, eof.promise]);
proc.kill();
await proc.exited;
proc.terminal?.close();
output += decoder.decode();
// ConPTY injects VT escape sequences and CR around the payload; strip
// them so the RESULT JSON can be matched regardless of where the
// terminal emulator decides to park the cursor.
const stripped = Bun.stripANSI(output).replace(/[\r\n]/g, "");
// Bun.Terminal always gives the child a TTY stdin (openpty / ConPTY). If
// RESULT is missing for any reason, surface the raw terminal output
// rather than a bare null match.
const match = stripped.match(/RESULT (\{[^}]*\})/);
if (!match) {
throw new Error("child did not emit RESULT; terminal output was: " + JSON.stringify(output));
}
expect(JSON.parse(match[1])).toEqual({
isTTY: true,
before: false,
afterTrue: true,
afterFalse: false,
returnsThis: true,
});
});
// Raw mode is per-stream in libuv (each uv_tty_t holds its own mode and its
// own saved termios), so a second tty.ReadStream on the same fd must not be
// able to restore the terminal out from under the stream that raw'd it.
// Bun used to keep one process-wide mode + termios snapshot, which turned
// `setRawMode(false)` on a never-raw stream into a real tcsetattr.
test.skipIf(isWindows)("a second ReadStream's setRawMode does not disturb process.stdin", async () => {
const ICANON = process.platform === "darwin" ? 0x100 : 0x2;
const ECHO = 0x8;
const decoder = new TextDecoder();
let buffer = "";
const waiters: { marker: string; resolve: () => void }[] = [];
await using terminal = new Bun.Terminal({
data(_terminal, chunk: Uint8Array) {
buffer += decoder.decode(chunk, { stream: true });
for (let i = waiters.length - 1; i >= 0; i--) {
if (buffer.includes(waiters[i].marker)) {
waiters[i].resolve();
waiters.splice(i, 1);
}
}
},
});
const isRaw = () => (terminal.localFlags & (ICANON | ECHO)) === 0;
const observed: Record<string, boolean> = { beforeSpawn: isRaw() };
// Each phase announces itself, then blocks on stdin so the parent can read
// termios while the child is still alive, and releases on the ack byte.
const proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
const tty = require("node:tty");
const { TTY } = process.binding("tty_wrap");
const say = s => process.stdout.write(s + "\\n");
const ack = () => new Promise(resolve => process.stdin.once("data", () => resolve()));
(async () => {
process.stdin.resume();
process.stdin.setRawMode(true);
say("P1"); await ack();
const second = new tty.ReadStream(0);
second.setRawMode(false); // never raw: must be a no-op
say("P2"); await ack();
second.setRawMode(true);
second.setRawMode(false); // restores its own snapshot, which was already raw
say("P3"); await ack();
new TTY(0).setRawMode(0); // same, through the tty_wrap binding
say("P4"); await ack();
process.stdin.setRawMode(false); // the stream that raw'd it restores cooked
say("P5"); await ack();
process.exit(0);
})();
`,
],
env: bunEnv,
terminal,
});
// A child that dies early must reject the phase waits rather than hang them.
const exitedEarly = proc.exited.then(code => {
throw new Error(`child exited early with code ${code}; terminal output: ${JSON.stringify(buffer)}`);
});
exitedEarly.catch(() => {});
const phase = (marker: string) => {
const seen = buffer.includes(marker)
? Promise.resolve()
: new Promise<void>(resolve => waiters.push({ marker, resolve }));
return Promise.race([seen, exitedEarly]);
};
await phase("P1");
observed.afterStdinRaw = isRaw();
terminal.write("\n");
await phase("P2");
observed.afterSecondStreamCooked = isRaw();
terminal.write("\n");
await phase("P3");
observed.afterSecondStreamRoundTrip = isRaw();
terminal.write("\n");
await phase("P4");
observed.afterTTYWrapCooked = isRaw();
terminal.write("\n");
await phase("P5");
observed.afterStdinCooked = isRaw();
terminal.write("\n");
expect(observed).toEqual({
beforeSpawn: false,
afterStdinRaw: true,
afterSecondStreamCooked: true,
afterSecondStreamRoundTrip: true,
afterTTYWrapCooked: true,
afterStdinCooked: false,
});
expect(await proc.exited).toBe(0);
});
});
describe("WriteStream.prototype.getColorDepth", () => {
const getColorDepth = (env: Record<string, string>) => WriteStream.prototype.getColorDepth.call(undefined, env);
// Expected values come from running the same env objects through Node
// v26.3.0. On Windows the OS build number decides instead of TERM/CI/
// COLORTERM, so the env matrix is only meaningful on POSIX.
const cases: [env: Record<string, string>, depth: number][] = [
[{ TERM: "dumb" }, 1],
[{ TERM: "dumb", COLORTERM: "truecolor" }, 1],
[{ NO_COLOR: "1", COLORTERM: "24bit" }, 1],
[{ NO_COLOR: "", COLORTERM: "24bit" }, 24],
[{ NO_COLOR: "", TERM: "xterm-256color" }, 8],
[{ NODE_DISABLE_COLORS: "1", TERM: "color" }, 1],
[{ NODE_DISABLE_COLORS: "", TERM: "xterm" }, 4],
[{ FORCE_COLOR: "" }, 4],
[{ FORCE_COLOR: "1" }, 4],
[{ FORCE_COLOR: "true" }, 4],
[{ FORCE_COLOR: "2" }, 8],
[{ FORCE_COLOR: "3" }, 24],
[{ FORCE_COLOR: "0" }, 1],
[{ FORCE_COLOR: "junk" }, 1],
[{ NO_COLOR: "1", FORCE_COLOR: "2" }, 8],
[{ NODE_DISABLE_COLORS: "1", FORCE_COLOR: "3" }, 24],
[{ COLORTERM: "24bit", FORCE_COLOR: "" }, 4],
[{ TMUX: "1" }, 24],
[{ TMUX: "1", COLORTERM: "truecolor" }, 24],
[{ TMUX: "1", TERM: "tmux-256color" }, 24],
[{ TF_BUILD: "1", AGENT_NAME: "x" }, 4],
[{ TF_BUILD: "1" }, 1],
[{ CI: "1" }, 1],
[{ CI: "" }, 1],
[{ CI: "1", APPVEYOR: "1" }, 8],
[{ CI: "1", BUILDKITE: "1" }, 8],
[{ CI: "1", CIRCLECI: "1" }, 24],
[{ CI: "1", DRONE: "1" }, 8],
[{ CI: "1", GITEA_ACTIONS: "1" }, 24],
[{ CI: "1", GITHUB_ACTIONS: "1" }, 24],
[{ CI: "1", GITLAB_CI: "1" }, 8],
[{ CI: "1", TRAVIS: "1" }, 8],
[{ CI: "1", CI_NAME: "codeship" }, 8],
[{ TEAMCITY_VERSION: "9.0.5 (build 32523)" }, 1],
[{ TEAMCITY_VERSION: "9.1.0 (build 32523)" }, 4],
[{ TERM_PROGRAM: "iTerm.app" }, 8],
[{ TERM_PROGRAM: "iTerm.app", TERM_PROGRAM_VERSION: "2.1" }, 8],
[{ TERM_PROGRAM: "iTerm.app", TERM_PROGRAM_VERSION: "3.2" }, 24],
[{ TERM_PROGRAM: "HyperTerm" }, 24],
[{ TERM_PROGRAM: "MacTerm" }, 24],
[{ TERM_PROGRAM: "Apple_Terminal" }, 8],
[{ COLORTERM: "truecolor" }, 24],
[{ COLORTERM: "24bit" }, 24],
[{ COLORTERM: "1" }, 4],
[{ TERM: "xterm" }, 4],
[{ TERM: "xterm", COLORTERM: "truecolor" }, 24],
[{ TERM: "xterm-256" }, 8],
[{ TERM: "xterm-256color" }, 8],
[{ TERM: "xterm-kitty" }, 24],
[{ TERM: "xterm-direct" }, 4],
[{ TERM: "screen.xterm-truecolor" }, 24],
[{ TERM: "rxvt-unicode-24bit" }, 24],
[{ TERM: "rxvt" }, 4],
[{ TERM: "vt100" }, 4],
[{ TERM: "vt220" }, 4],
[{ TERM: "konsole" }, 4],
[{ TERM: "KONSOLE" }, 4],
[{ TERM: "mosh" }, 24],
[{ TERM: "terminator" }, 24],
[{ TERM: "st" }, 4],
[{ TERM: "linux" }, 4],
[{ TERM: "ansi" }, 4],
[{ TERM: "ANSI" }, 4],
[{ TERM: "color" }, 4],
[{ TERM: "con132x25" }, 4],
[{ TERM: "fail" }, 1],
[{ TERM: "" }, 1],
[{ COLORTERM: "ansi256" }, 4],
];
it.skipIf(isWindows)("matches Node across the TERM/COLORTERM/CI env matrix", () => {
const results = cases.map(([env, expected]) => ({ env, expected, actual: getColorDepth(env) }));
expect(results.filter(r => r.actual !== r.expected)).toEqual([]);
});
// Bun recognizes these truecolor terminals on top of Node's list.
it.skipIf(isWindows)("reports 24-bit color for ghostty and WezTerm", () => {
expect(getColorDepth({ TERM_PROGRAM: "ghostty" })).toBe(24);
expect(getColorDepth({ TERM_PROGRAM: "WezTerm" })).toBe(24);
});
it("iTerm ancient", () => {
expect(
WriteStream.prototype.getColorDepth.call(undefined, {
TERM_PROGRAM: "iTerm.app",
}),
).toBe(isWindows ? 24 : 8);
});
it("iTerm modern", () => {
expect(
WriteStream.prototype.getColorDepth.call(undefined, {
TERM_PROGRAM: "iTerm.app",
TERM_PROGRAM_VERSION: 3,
}),
).toBe(24);
});
it("empty", () => {
expect(WriteStream.prototype.getColorDepth.call(undefined, {})).toBe(isWindows ? 24 : 1);
});
});