Files
oh-my-pi/packages/tui/test/terminal-appearance.test.ts
T
can1357 48766eb88a fix(tui): kept ProcessTerminal and window-title writes headless under the test runtime
- `ProcessTerminal` now captures a per-instance `#headless` from `isTerminalHeadless()` at construction and re-reads it in `start()`; when set, `#safeWrite`, `start`, `stop`, `drainInput`, and `setProgress` short-circuit, so frame paints, `start()` probes (OSC 11 / DA1 / kitty), the progress keepalive, stdin raw mode, SIGWINCH, and teardown escapes no longer reach a real terminal during `bun test` (previously `#safeWrite` only skipped on `!isTTY`, so an interactive terminal leaked stray boxes and probe queries).
- Gated the `emergencyTerminalRestore()` blind-restore branch and `TerminalInfo.sendNotification` on the headless flag, and guarded `setTerminalTitle`/`pushTerminalTitle`/`popTerminalTitle` in `title-generator.ts`.
- Opted the terminal-contract suites (terminal-appearance, notifications, emergency-restore-altscreen, issue-2034 write gate, process-terminal-render-harness, render-stress-harness) back into real I/O with `setTerminalHeadless(false)`, restoring on teardown.
- Added `process-terminal-headless.test.ts` asserting zero writes under a forced TTY when headless and real frame/probe output after opt-out.
2026-06-17 01:21:03 +02:00

750 lines
28 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import { extractPrintableText } from "@oh-my-pi/pi-tui/keys";
import { ProcessTerminal } from "@oh-my-pi/pi-tui/terminal";
import {
type CellDimensions,
getCellDimensions,
getTerminalInfo,
setCellDimensions,
} from "@oh-my-pi/pi-tui/terminal-capabilities";
import { setTerminalHeadless } from "@oh-my-pi/pi-utils";
const stdinIsTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
const stdoutIsTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
const processPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
const stdoutColumnsDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "columns");
const stdoutRowsDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "rows");
const stdinSetRawModeDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "setRawMode");
const originalWslDistroName = Bun.env.WSL_DISTRO_NAME;
const originalWslInterop = Bun.env.WSL_INTEROP;
// These suites drive the real ProcessTerminal start()/probe pipeline, so they
// opt out of the test-default headless suppression and restore it per case.
let previousHeadless = false;
function restoreProperty(target: object, key: string, descriptor: PropertyDescriptor | undefined): void {
if (descriptor) {
Object.defineProperty(target, key, descriptor);
return;
}
delete (target as Record<string, unknown>)[key];
}
function restoreEnv(key: string, original: string | undefined): void {
if (original === undefined) {
delete Bun.env[key];
return;
}
Bun.env[key] = original;
}
describe("ProcessTerminal OSC 11 appearance detection", () => {
beforeEach(() => {
Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true });
Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true });
Object.defineProperty(process.stdin, "setRawMode", { value: vi.fn(), configurable: true });
previousHeadless = setTerminalHeadless(false);
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
setTerminalHeadless(previousHeadless);
restoreProperty(process.stdin, "isTTY", stdinIsTtyDescriptor);
restoreProperty(process.stdout, "isTTY", stdoutIsTtyDescriptor);
restoreProperty(process.stdin, "setRawMode", stdinSetRawModeDescriptor);
restoreProperty(process, "platform", processPlatformDescriptor);
restoreEnv("WSL_INTEROP", originalWslInterop);
restoreEnv("WSL_DISTRO_NAME", originalWslDistroName);
});
function setupTerminal() {
const writes: string[] = [];
const received: string[] = [];
vi.spyOn(process, "kill").mockReturnValue(true);
vi.spyOn(process.stdin, "resume").mockImplementation(() => process.stdin);
vi.spyOn(process.stdin, "pause").mockImplementation(() => process.stdin);
vi.spyOn(process.stdin, "setEncoding").mockImplementation(() => process.stdin);
vi.spyOn(process.stdout, "write").mockImplementation(chunk => {
writes.push(typeof chunk === "string" ? chunk : chunk.toString());
return true;
});
const terminal = new ProcessTerminal();
terminal.start(
data => received.push(data),
() => {},
);
const queryCount = () => writes.filter(w => w === "\x1b]11;?\x07").length;
const sentinelCount = () => writes.filter(w => w === "\x1b[c").length;
return { terminal, writes, received, queryCount, sentinelCount };
}
it("swallows the DA1 sentinel even when the OSC 11 reply arrives first", () => {
const { terminal, writes, received } = setupTerminal();
process.stdin.emit("data", "\x1b]11;rgb:ffff/ffff/ffff\x07");
process.stdin.emit("data", "\x1b[?1;2c");
expect(received).toEqual([]);
expect(writes).toContain("\x1b]11;?\x07");
expect(writes).toContain("\x1b[c");
terminal.stop();
});
it("queues overlapping OSC 11 queries until both in-flight DA1 sentinels are consumed", () => {
vi.useFakeTimers();
const { terminal, queryCount, sentinelCount } = setupTerminal();
// Startup writes one OSC 11 query and one OSC 11 DA1 sentinel; the kitty
// keyboard probe's sentinel is fused into a combined `\x1b[?u\x1b[c` write,
// so it does not appear under the bare `\x1b[c` filter.
expect(queryCount()).toBe(1);
expect(sentinelCount()).toBe(1);
process.stdin.emit("data", "\x1b[?997;1n");
vi.advanceTimersByTime(100);
expect(queryCount()).toBe(1);
expect(sentinelCount()).toBe(1);
// First DA1 drains the keyboard sentinel; OSC 11 still pending.
process.stdin.emit("data", "\x1b[?1;2c");
expect(queryCount()).toBe(1);
// Second DA1 drains the OSC 11 sentinel and kicks the queued re-query.
process.stdin.emit("data", "\x1b[?1;2c");
expect(queryCount()).toBe(2);
expect(sentinelCount()).toBe(2);
terminal.stop();
});
it("OSC 11 updates terminal.appearance and fires callbacks with dedup", () => {
const { terminal } = setupTerminal();
const appearances: string[] = [];
terminal.onAppearanceChange(a => appearances.push(a));
// Send dark background response + DA1
process.stdin.emit("data", "\x1b]11;rgb:0000/0000/0000\x07");
process.stdin.emit("data", "\x1b[?1;2c");
expect(terminal.appearance).toBe("dark");
expect(appearances).toEqual(["dark"]);
// Send same color again — callback should NOT fire again
process.stdin.emit("data", "\x1b]11;rgb:0000/0000/0000\x07");
process.stdin.emit("data", "\x1b[?1;2c");
expect(appearances).toEqual(["dark"]);
terminal.stop();
});
it("2-digit hex OSC 11 response is correctly normalized", () => {
const { terminal } = setupTerminal();
// Send dark 2-digit response + DA1
process.stdin.emit("data", "\x1b]11;rgb:1a/1a/1a\x07");
process.stdin.emit("data", "\x1b[?1;2c");
expect(terminal.appearance).toBe("dark");
terminal.stop();
});
it("2-digit hex light background is detected correctly", () => {
const { terminal } = setupTerminal();
process.stdin.emit("data", "\x1b]11;rgb:ff/ff/ff\x07");
process.stdin.emit("data", "\x1b[?1;2c");
expect(terminal.appearance).toBe("light");
terminal.stop();
});
it("Mode 2031 debounce: multiple notifications coalesce into one re-query", () => {
vi.useFakeTimers();
const { terminal, queryCount } = setupTerminal();
// Complete the initial OSC 11 + DA1 cycle (2 startup DA1 sentinels: keyboard + OSC 11)
process.stdin.emit("data", "\x1b]11;rgb:ffff/ffff/ffff\x07");
process.stdin.emit("data", "\x1b[?1;2c");
process.stdin.emit("data", "\x1b[?1;2c");
const baseline = queryCount();
// Send 3 rapid Mode 2031 notifications
process.stdin.emit("data", "\x1b[?997;1n");
process.stdin.emit("data", "\x1b[?997;1n");
process.stdin.emit("data", "\x1b[?997;1n");
// Advance past debounce
vi.advanceTimersByTime(100);
// Only one additional query should have been sent (debounced)
expect(queryCount()).toBe(baseline + 1);
terminal.stop();
});
it("poll timer self-disables when Mode 2031 fires outside WSL", () => {
vi.useFakeTimers();
const { terminal, queryCount } = setupTerminal();
// Complete initial OSC 11 + DA1 cycle. Two DA1 sentinels are in flight at
// startup (keyboard probe + OSC 11), so emit two DA1 replies.
process.stdin.emit("data", "\x1b]11;rgb:ffff/ffff/ffff\x07");
process.stdin.emit("data", "\x1b[?1;2c");
process.stdin.emit("data", "\x1b[?1;2c");
const afterInitial = queryCount();
// Advance one poll interval — poll should fire and send another query
vi.advanceTimersByTime(30_000);
expect(queryCount()).toBe(afterInitial + 1);
// Complete poll's OSC 11 + DA1 (only one DA1 sentinel — keyboard probe is one-shot)
process.stdin.emit("data", "\x1b]11;rgb:ffff/ffff/ffff\x07");
process.stdin.emit("data", "\x1b[?1;2c");
// Send Mode 2031 notification — this activates push mode and stops polling
process.stdin.emit("data", "\x1b[?997;1n");
vi.advanceTimersByTime(100);
// Complete Mode 2031's re-query
process.stdin.emit("data", "\x1b]11;rgb:0000/0000/0000\x07");
process.stdin.emit("data", "\x1b[?1;2c");
const afterMode2031 = queryCount();
// Advance two more poll intervals — no additional poll queries should fire
vi.advanceTimersByTime(60_000);
expect(queryCount()).toBe(afterMode2031);
terminal.stop();
});
it("poll timer stops once DECRQM confirms Mode 2031 support", () => {
vi.useFakeTimers();
const { terminal, queryCount } = setupTerminal();
// Complete initial OSC 11 + DA1 cycle (keyboard + OSC 11 sentinels).
process.stdin.emit("data", "\x1b]11;rgb:ffff/ffff/ffff\x07");
process.stdin.emit("data", "\x1b[?1;2c");
process.stdin.emit("data", "\x1b[?1;2c");
// Poll fires at the first interval while Mode 2031 support is still unknown.
const afterInitial = queryCount();
vi.advanceTimersByTime(30_000);
expect(queryCount()).toBe(afterInitial + 1);
// Drain the poll's OSC 11 reply so it is no longer pending.
process.stdin.emit("data", "\x1b]11;rgb:ffff/ffff/ffff\x07");
// DECRQM confirms Mode 2031 support — push notifications supersede polling,
// so the poll must stop (its repeated OSC 11/DA1 writes otherwise clobber
// the user's active text selection on every poll).
process.stdin.emit("data", "\x1b[?2031;3$y");
const afterConfirm = queryCount();
// Advance well past several poll intervals — no further OSC 11 queries fire.
vi.advanceTimersByTime(90_000);
expect(queryCount()).toBe(afterConfirm);
terminal.stop();
});
it("does not start the OSC 11 poll timer under WSL", () => {
vi.useFakeTimers();
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
Bun.env.WSL_INTEROP = "/run/WSL/1_interop";
const { terminal, queryCount } = setupTerminal();
process.stdin.emit("data", "\x1b]11;rgb:ffff/ffff/ffff\x07");
process.stdin.emit("data", "\x1b[?1;2c");
process.stdin.emit("data", "\x1b[?1;2c");
const afterInitial = queryCount();
vi.advanceTimersByTime(90_000);
expect(queryCount()).toBe(afterInitial);
terminal.stop();
});
it("partial OSC 11 buffer does not swallow unrelated input", () => {
vi.useFakeTimers();
const { terminal, received } = setupTerminal();
// Send a partial OSC 11 start (no terminator)
process.stdin.emit("data", "\x1b]11;rgb:ff");
// Flush StdinBuffer timeout so the partial sequence is emitted
vi.advanceTimersByTime(50);
// Send an unrelated escape sequence (up arrow)
process.stdin.emit("data", "\x1b[A");
vi.advanceTimersByTime(50);
// The up arrow must be forwarded to the input handler
expect(received).toContain("\x1b[A");
terminal.stop();
});
it("DA1 from old query does not cancel new queued query", () => {
vi.useFakeTimers();
const { terminal, queryCount, sentinelCount } = setupTerminal();
const appearances: string[] = [];
terminal.onAppearanceChange(a => appearances.push(a));
// Step 1: initial query was sent on start
expect(queryCount()).toBe(1);
expect(sentinelCount()).toBe(1);
// Step 2: Mode 2031 notification arrives — queues re-query since initial is pending
process.stdin.emit("data", "\x1b[?997;1n");
// Step 3: Complete initial OSC 11 response (light)
process.stdin.emit("data", "\x1b]11;rgb:ffff/ffff/ffff\x07");
// Advance past debounce timer
vi.advanceTimersByTime(100);
// Step 4: Complete both initial DA1 sentinels — keyboard probe first, then OSC 11.
// The keyboard sentinel doesn't kick the queued OSC 11 query; the OSC 11 one does.
process.stdin.emit("data", "\x1b[?1;2c");
process.stdin.emit("data", "\x1b[?1;2c");
expect(queryCount()).toBe(2);
expect(sentinelCount()).toBe(2);
// Step 5: Complete 2nd OSC 11 response with a different color (dark)
process.stdin.emit("data", "\x1b]11;rgb:0000/0000/0000\x07");
// Step 6: Complete 2nd DA1
process.stdin.emit("data", "\x1b[?1;2c");
// Step 7: Verify appearance changed and callback fired
expect(terminal.appearance).toBe("dark");
expect(appearances).toContain("light");
expect(appearances).toContain("dark");
expect(appearances.length).toBe(2);
terminal.stop();
});
it("reassembles a DA1 response split across stdin reads without leaking to input (#1238)", () => {
vi.useFakeTimers();
const { terminal, received } = setupTerminal();
// OSC 11 completes normally.
process.stdin.emit("data", "\x1b]11;rgb:1c1c/1c1c/1c1c\x07");
// DA1 reply arrives split: the prefix appears as one event and then the StdinBuffer
// flush timeout (50ms) elapses before the rest of the response is delivered.
// xterm-style "VT420 with extensions" response: \x1b[?62;6;7;14;...;52c
process.stdin.emit("data", "\x1b[?62");
vi.advanceTimersByTime(50);
process.stdin.emit("data", ";6;7;14;21;22;23;24;28;32;42;52c");
expect(received).toEqual([]);
expect(terminal.appearance).toBe("dark");
terminal.stop();
});
it("reassembles a DA1 response delivered byte-by-byte", () => {
vi.useFakeTimers();
const { terminal, received } = setupTerminal();
process.stdin.emit("data", "\x1b]11;rgb:1c1c/1c1c/1c1c\x07");
process.stdin.emit("data", "\x1b[?62");
vi.advanceTimersByTime(50);
for (const ch of ";6;7;14;21;22;23;24;28;32;42;52c") {
process.stdin.emit("data", ch);
}
expect(received).toEqual([]);
expect(terminal.appearance).toBe("dark");
terminal.stop();
});
it("abandons private CSI reassembly when a new escape arrives mid-stream", () => {
vi.useFakeTimers();
const { terminal, received } = setupTerminal();
// Start a partial DA1, then a fresh CSI (up arrow) interrupts before the terminator.
process.stdin.emit("data", "\x1b[?62");
vi.advanceTimersByTime(50);
process.stdin.emit("data", "\x1b[A");
vi.advanceTimersByTime(50);
// Up arrow must reach the input handler; probe noise must not.
expect(received).toContain("\x1b[A");
expect(received.some(seq => seq.includes("?62"))).toBe(false);
terminal.stop();
});
it("kitty keyboard probe owns its own DA1 sentinel — does not consume OSC 11's", () => {
const { terminal, writes, received } = setupTerminal();
// The probe must use `\x1b[?u` (query only). Pushing `\x1b[>31u` would
// leak a frame onto the kitty stack that shutdown's single pop cannot balance.
expect(writes.some(w => w.includes("\x1b[>31u"))).toBe(false);
expect(writes).toContain("\x1b[?u\x1b[c");
// Seven DA1 sentinels are in flight at startup: keyboard probe, OSC 11, and
// the DECRQM probes for DEC 2026, 2048, 2031, 1010, and 1011 (each rides the
// shared FIFO). Consume them in send-order and verify none leaks to the input
// handler.
process.stdin.emit("data", "\x1b[?1;2c");
process.stdin.emit("data", "\x1b[?1;2c");
process.stdin.emit("data", "\x1b[?1;2c");
process.stdin.emit("data", "\x1b[?1;2c");
process.stdin.emit("data", "\x1b[?1;2c");
process.stdin.emit("data", "\x1b[?1;2c");
process.stdin.emit("data", "\x1b[?1;2c");
expect(received).toEqual([]);
// An eighth stray DA1 has no owner and must reach the input handler — it is
process.stdin.emit("data", "\x1b[?1;2c");
expect(received).toEqual(["\x1b[?1;2c"]);
terminal.stop();
});
it("keyboard DA1 arriving before OSC 11 reply does not falsely mark OSC 11 unsupported", () => {
const { terminal } = setupTerminal();
// Keyboard's DA1 arrives first (sent-order). OSC 11 must remain pending.
process.stdin.emit("data", "\x1b[?1;2c");
// OSC 11 reply still arrives after — its handler should still parse it
// (osc11Pending must not have been cleared by the keyboard DA1).
process.stdin.emit("data", "\x1b]11;rgb:0000/0000/0000\x07");
expect(terminal.appearance).toBe("dark");
// OSC 11's own DA1 sentinel drains the FIFO without re-entering the bug path.
process.stdin.emit("data", "\x1b[?1;2c");
terminal.stop();
});
it("shutdown balances the single kitty push performed on detection", () => {
const { terminal, writes } = setupTerminal();
// Simulate kitty-capable terminal reply (level >=1).
process.stdin.emit("data", "\x1b[?1u");
const pushes = writes.filter(w => w === "\x1b[>1u" || w === "\x1b[>7u" || w === "\x1b[>31u").length;
expect(pushes).toBe(1);
terminal.stop();
const pops = writes.filter(w => w === "\x1b[<u").length;
expect(pops).toBe(1);
});
});
describe("ProcessTerminal DECRQM + in-band resize (DEC 2026/2048)", () => {
let originalCellDims: CellDimensions;
beforeEach(() => {
Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true });
Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true });
Object.defineProperty(process.stdin, "setRawMode", { value: vi.fn(), configurable: true });
originalCellDims = { ...getCellDimensions() };
previousHeadless = setTerminalHeadless(false);
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
setTerminalHeadless(previousHeadless);
restoreProperty(process.stdin, "isTTY", stdinIsTtyDescriptor);
restoreProperty(process.stdout, "isTTY", stdoutIsTtyDescriptor);
restoreProperty(process.stdin, "setRawMode", stdinSetRawModeDescriptor);
restoreProperty(process, "platform", processPlatformDescriptor);
restoreProperty(process.stdout, "columns", stdoutColumnsDescriptor);
restoreProperty(process.stdout, "rows", stdoutRowsDescriptor);
setCellDimensions(originalCellDims);
});
function setup() {
const writes: string[] = [];
const received: string[] = [];
let resizeCount = 0;
const reports: Array<{ mode: number; supported: boolean }> = [];
vi.spyOn(process, "kill").mockReturnValue(true);
vi.spyOn(process.stdin, "resume").mockImplementation(() => process.stdin);
vi.spyOn(process.stdin, "pause").mockImplementation(() => process.stdin);
vi.spyOn(process.stdin, "setEncoding").mockImplementation(() => process.stdin);
vi.spyOn(process.stdout, "write").mockImplementation(chunk => {
writes.push(typeof chunk === "string" ? chunk : chunk.toString());
return true;
});
const terminal = new ProcessTerminal();
terminal.onPrivateModeReport?.((mode, supported) => reports.push({ mode, supported }));
terminal.start(
data => received.push(data),
() => {
resizeCount++;
},
);
return { terminal, writes, received, reports, resizeCount: () => resizeCount };
}
it("queries DECRQM for DEC 2026, 2048, 2031, and xterm scroll-to-bottom modes at startup", () => {
const { terminal, writes } = setup();
expect(writes.some(w => w.includes("\x1b[?2026$p"))).toBe(true);
expect(writes.some(w => w.includes("\x1b[?2048$p"))).toBe(true);
expect(writes.some(w => w.includes("\x1b[?2031$p"))).toBe(true);
expect(writes.some(w => w.includes("\x1b[?1010$p"))).toBe(true);
expect(writes.some(w => w.includes("\x1b[?1011$p"))).toBe(true);
terminal.stop();
});
it("reports DECRPM statuses 1, 2, and 3 as supported private modes", () => {
const { terminal, reports } = setup();
process.stdin.emit("data", "\x1b[?2026;1$y");
process.stdin.emit("data", "\x1b[?2048;2$y");
process.stdin.emit("data", "\x1b[?2031;3$y");
expect(reports).toContainEqual({ mode: 2026, supported: true });
expect(reports).toContainEqual({ mode: 2048, supported: true });
expect(reports).toContainEqual({ mode: 2031, supported: true });
terminal.stop();
});
it("reports DECRPM status 4 as unsupported for modes the TUI enables", () => {
const { terminal, writes, reports } = setup();
process.stdin.emit("data", "\x1b[?2026;4$y");
process.stdin.emit("data", "\x1b[?2048;4$y");
expect(reports).toContainEqual({ mode: 2026, supported: false });
expect(reports).toContainEqual({ mode: 2048, supported: false });
expect(writes).not.toContain("\x1b[?2048h");
terminal.stop();
expect(writes).not.toContain("\x1b[?2048l");
});
it("reports a private mode unsupported when DECRPM status is 0", () => {
const { terminal, reports } = setup();
process.stdin.emit("data", "\x1b[?2026;0$y");
expect(reports).toContainEqual({ mode: 2026, supported: false });
terminal.stop();
});
it("enables DEC 2048 only after DECRPM confirms support, and disables it on stop", () => {
const { terminal, writes, reports } = setup();
expect(writes).not.toContain("\x1b[?2048h");
process.stdin.emit("data", "\x1b[?2048;2$y");
expect(reports).toContainEqual({ mode: 2048, supported: true });
expect(writes).toContain("\x1b[?2048h");
terminal.stop();
expect(writes).toContain("\x1b[?2048l");
});
it("disables xterm scroll-to-bottom modes while active and restores them on stop", () => {
const { terminal, writes, reports } = setup();
process.stdin.emit("data", "\x1b[?1010;1$y");
process.stdin.emit("data", "\x1b[?1011;3$y");
expect(reports).toContainEqual({ mode: 1010, supported: true });
expect(reports).toContainEqual({ mode: 1011, supported: true });
expect(writes).toContain("\x1b[?1010l");
expect(writes).toContain("\x1b[?1011l");
terminal.stop();
expect(writes).toContain("\x1b[?1010h");
expect(writes).toContain("\x1b[?1011h");
});
it("leaves already-reset xterm scroll-to-bottom modes unchanged", () => {
const { terminal, writes, reports } = setup();
process.stdin.emit("data", "\x1b[?1010;2$y");
process.stdin.emit("data", "\x1b[?1011;4$y");
expect(reports).toContainEqual({ mode: 1010, supported: true });
expect(reports).toContainEqual({ mode: 1011, supported: false });
expect(writes).not.toContain("\x1b[?1010l");
expect(writes).not.toContain("\x1b[?1011l");
terminal.stop();
expect(writes).not.toContain("\x1b[?1010h");
expect(writes).not.toContain("\x1b[?1011h");
});
it("does not enable DEC 2048 when reported unsupported", () => {
const { terminal, writes, reports } = setup();
process.stdin.emit("data", "\x1b[?2048;0$y");
expect(reports).toContainEqual({ mode: 2048, supported: false });
expect(writes).not.toContain("\x1b[?2048h");
terminal.stop();
expect(writes).not.toContain("\x1b[?2048l");
});
it("falls back to unsupported when the DA1 sentinel beats the DECRPM reply", () => {
const { terminal, reports } = setup();
// Drain keyboard + osc11 sentinels, then 2026's DA1 (no DECRPM arrived).
process.stdin.emit("data", "\x1b[?1;2c");
process.stdin.emit("data", "\x1b[?1;2c");
process.stdin.emit("data", "\x1b[?1;2c");
expect(reports).toContainEqual({ mode: 2026, supported: false });
terminal.stop();
});
it("updates geometry and cell size without resizing when an in-band report is unchanged", () => {
Object.defineProperty(process.stdout, "columns", { value: 100, configurable: true });
Object.defineProperty(process.stdout, "rows", { value: 30, configurable: true });
const { terminal, received, resizeCount } = setup();
process.stdin.emit("data", "\x1b[?2048;1$y");
process.stdin.emit("data", "\x1b[48;30;100;600;1000t");
expect(terminal.rows).toBe(30);
expect(terminal.columns).toBe(100);
expect(getCellDimensions()).toEqual({ widthPx: 10, heightPx: 20 });
expect(resizeCount()).toBe(0);
expect(received).toEqual([]);
terminal.stop();
});
it("fires resize once when an in-band report changes rows or columns", () => {
Object.defineProperty(process.stdout, "columns", { value: 100, configurable: true });
Object.defineProperty(process.stdout, "rows", { value: 30, configurable: true });
const { terminal, received, resizeCount } = setup();
process.stdin.emit("data", "\x1b[?2048;1$y");
process.stdin.emit("data", "\x1b[48;31;120;620;1200t");
expect(terminal.rows).toBe(31);
expect(terminal.columns).toBe(120);
expect(getCellDimensions()).toEqual({ widthPx: 10, heightPx: 20 });
expect(resizeCount()).toBe(1);
expect(received).toEqual([]);
terminal.stop();
});
it("tracks OS geometry on resize when the post-resize in-band report is missed", () => {
// Real terminals always fire SIGWINCH (process.stdout dims refresh first),
// but the matching DEC 2048 report can be dropped or arrive malformed. The
// getters must not stay pinned to the stale cached report, or the renderer
// reflows at the old width and content never resizes.
Object.defineProperty(process.stdout, "columns", { value: 100, configurable: true });
Object.defineProperty(process.stdout, "rows", { value: 30, configurable: true });
const { terminal, resizeCount } = setup();
process.stdin.emit("data", "\x1b[?2048;1$y");
process.stdin.emit("data", "\x1b[48;30;100;600;1000t");
expect(terminal.columns).toBe(100);
expect(terminal.rows).toBe(30);
// OS resize: stdout dims update + 'resize' fires, no new in-band report.
Object.defineProperty(process.stdout, "columns", { value: 160, configurable: true });
Object.defineProperty(process.stdout, "rows", { value: 40, configurable: true });
process.stdout.emit("resize");
expect(resizeCount()).toBe(1);
expect(terminal.columns).toBe(160);
expect(terminal.rows).toBe(40);
terminal.stop();
});
it("reassembles a DECRPM reply split across stdin reads", () => {
vi.useFakeTimers();
const { terminal, reports } = setup();
process.stdin.emit("data", "\x1b[?2048;1");
vi.advanceTimersByTime(50);
process.stdin.emit("data", "$y");
expect(reports).toContainEqual({ mode: 2048, supported: true });
terminal.stop();
});
it("reassembles an in-band resize report split past the flush window without leaking the tail", () => {
// The reported bug: resizing rapidly keeps the event loop busy, so the
// StdinBuffer flush timeout (50ms) fires after the `\x1b[48;…` prefix but
// before the terminator. The tail then arrives as bare characters that
// leaked into the editor as literal text (e.g. `8;125;1156;1125t`).
vi.useFakeTimers();
Object.defineProperty(process.stdout, "columns", { value: 100, configurable: true });
Object.defineProperty(process.stdout, "rows", { value: 30, configurable: true });
const { terminal, received, resizeCount } = setup();
process.stdin.emit("data", "\x1b[?2048;1$y"); // in-band active
process.stdin.emit("data", "\x1b[48;40;160");
vi.advanceTimersByTime(50); // flush window elapses mid-report
process.stdin.emit("data", ";800;1600t"); // tail arrives as bare chars
expect(received).toEqual([]);
expect(terminal.rows).toBe(40);
expect(terminal.columns).toBe(160);
expect(resizeCount()).toBe(1);
terminal.stop();
});
it("reassembles a well-formed report split at the type field (\\x1b[4 | 8;…t)", () => {
// Splitting right after `\x1b[4` is the exact shape from the bug report (ESC
// `[` `4` flushed, the rest leaking). Reassembly must catch the bare `\x1b[4`
// prefix and still apply the resize for a well-formed 5-field report.
vi.useFakeTimers();
Object.defineProperty(process.stdout, "columns", { value: 100, configurable: true });
Object.defineProperty(process.stdout, "rows", { value: 30, configurable: true });
const { terminal, received } = setup();
process.stdin.emit("data", "\x1b[?2048;1$y");
process.stdin.emit("data", "\x1b[4");
vi.advanceTimersByTime(50);
process.stdin.emit("data", "8;40;125;1156;1125t");
expect(received).toEqual([]);
expect(terminal.rows).toBe(40);
expect(terminal.columns).toBe(125);
terminal.stop();
});
it("forwards a split report fragment as one escape sequence instead of leaking bare characters", () => {
// The reported symptom: a fragment like `8;125;1156;1125t` (the tail of
// `\x1b[48;125;1156;1125t`, missing a field) appeared as literal text in the
// editor because the tail arrived as individual printable characters. Even
// when the reassembled sequence is not a valid resize report, it must reach
// the input handler as ONE escape sequence — `extractPrintableText` then
// rejects it (it contains ESC), so no characters are inserted.
vi.useFakeTimers();
const { terminal, received } = setup();
process.stdin.emit("data", "\x1b[?2048;1$y"); // in-band active
process.stdin.emit("data", "\x1b[4");
vi.advanceTimersByTime(50);
process.stdin.emit("data", "8;125;1156;1125t");
expect(received).toEqual(["\x1b[48;125;1156;1125t"]);
expect(received.every(seq => extractPrintableText(seq) === undefined)).toBe(true);
terminal.stop();
});
it("forwards a split kitty key colliding with the in-band prefix instead of swallowing it", () => {
// Kitty reports the '0' key (codepoint 48) as `\x1b[48;<mods>u`. If such a
// key is split past the flush window while in-band resize is active, the
// reassembled sequence is not a resize report and must reach the input
// handler — never be dropped as terminal noise.
vi.useFakeTimers();
const { terminal, received } = setup();
process.stdin.emit("data", "\x1b[?2048;1$y"); // in-band active
process.stdin.emit("data", "\x1b[48;5");
vi.advanceTimersByTime(50);
process.stdin.emit("data", "u");
expect(received).toEqual(["\x1b[48;5u"]);
terminal.stop();
});
});
describe("OSC 66 text-sizing capability", () => {
it("advertises text sizing only for Kitty", () => {
// OSC 66 is a Kitty-only protocol; any other terminal must report the
// capability as false so the renderer never emits raw escape bytes there.
expect(getTerminalInfo("kitty").textSizing).toBe(true);
for (const id of ["ghostty", "wezterm", "iterm2", "vscode", "alacritty", "base", "trueColor"] as const) {
expect(getTerminalInfo(id).textSizing).toBe(false);
}
});
});