From da6a2fb5eacb52aad46efc89708b0259841725fd Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 11 Jan 2026 19:01:05 +0100 Subject: [PATCH] test(tui): cover style reset cases --- packages/tui/test/markdown.test.ts | 48 +++++++++++++ .../tui/test/tui-overlay-style-leak.test.ts | 72 +++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 packages/tui/test/tui-overlay-style-leak.test.ts diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index d313902c..1bb2b240 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -1,12 +1,25 @@ import assert from "node:assert"; import { describe, it } from "node:test"; +import type { Terminal as XtermTerminalType } from "@xterm/headless"; import { Chalk } from "chalk"; import { Markdown } from "../src/components/markdown.js"; +import { type Component, TUI } from "../src/tui.js"; import { defaultMarkdownTheme } from "./test-themes.js"; +import { VirtualTerminal } from "./virtual-terminal.js"; // Force full color in CI so ANSI assertions are deterministic const chalk = new Chalk({ level: 3 }); +function getCellItalic(terminal: VirtualTerminal, row: number, col: number): number { + const xterm = (terminal as unknown as { xterm: XtermTerminalType }).xterm; + const buffer = xterm.buffer.active; + const line = buffer.getLine(buffer.viewportY + row); + assert.ok(line, `Missing buffer line at row ${row}`); + const cell = line.getCell(col); + assert.ok(cell, `Missing cell at row ${row} col ${col}`); + return cell.isItalic(); +} + describe("Markdown component", () => { describe("Nested lists", () => { it("should render simple nested list", () => { @@ -437,6 +450,41 @@ describe("Markdown component", () => { // Should have bold codes (1 or 22 for bold on/off) assert.ok(joinedOutput.includes("\x1b[1m"), "Should have bold code"); }); + + it("should not leak styles into following lines when rendered in TUI", async () => { + class MarkdownWithInput implements Component { + public markdownLineCount = 0; + + constructor(private readonly markdown: Markdown) {} + + render(width: number): string[] { + const lines = this.markdown.render(width); + this.markdownLineCount = lines.length; + return [...lines, "INPUT"]; + } + + invalidate(): void { + this.markdown.invalidate(); + } + } + + const markdown = new Markdown("This is thinking with `inline code`", 1, 0, defaultMarkdownTheme, { + color: (text) => chalk.gray(text), + italic: true, + }); + + const terminal = new VirtualTerminal(80, 6); + const tui = new TUI(terminal); + const component = new MarkdownWithInput(markdown); + tui.addChild(component); + tui.start(); + await terminal.flush(); + + assert.ok(component.markdownLineCount > 0); + const inputRow = component.markdownLineCount; + assert.strictEqual(getCellItalic(terminal, inputRow, 0), 0); + tui.stop(); + }); }); describe("Spacing after code blocks", () => { diff --git a/packages/tui/test/tui-overlay-style-leak.test.ts b/packages/tui/test/tui-overlay-style-leak.test.ts new file mode 100644 index 00000000..cf49f31b --- /dev/null +++ b/packages/tui/test/tui-overlay-style-leak.test.ts @@ -0,0 +1,72 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import type { Terminal as XtermTerminalType } from "@xterm/headless"; +import { type Component, TUI } from "../src/tui.js"; +import { VirtualTerminal } from "./virtual-terminal.js"; + +class StaticLines implements Component { + constructor(private readonly lines: string[]) {} + + render(): string[] { + return this.lines; + } + + invalidate(): void {} +} + +class StaticOverlay implements Component { + constructor(private readonly line: string) {} + + render(): string[] { + return [this.line]; + } + + invalidate(): void {} +} + +function getCellItalic(terminal: VirtualTerminal, row: number, col: number): number { + const xterm = (terminal as unknown as { xterm: XtermTerminalType }).xterm; + const buffer = xterm.buffer.active; + const line = buffer.getLine(buffer.viewportY + row); + assert.ok(line, `Missing buffer line at row ${row}`); + const cell = line.getCell(col); + assert.ok(cell, `Missing cell at row ${row} col ${col}`); + return cell.isItalic(); +} + +async function renderAndFlush(tui: TUI, terminal: VirtualTerminal): Promise { + tui.requestRender(true); + await new Promise((resolve) => process.nextTick(resolve)); + await terminal.flush(); +} + +describe("TUI overlay compositing", () => { + it("should not leak styles when a trailing reset sits beyond the last visible column (no overlay)", async () => { + const width = 20; + const baseLine = `\x1b[3m${"X".repeat(width)}\x1b[23m`; + + const terminal = new VirtualTerminal(width, 6); + const tui = new TUI(terminal); + tui.addChild(new StaticLines([baseLine, "INPUT"])); + tui.start(); + await renderAndFlush(tui, terminal); + assert.strictEqual(getCellItalic(terminal, 1, 0), 0); + tui.stop(); + }); + + it("should not leak styles when overlay slicing drops trailing SGR resets", async () => { + const width = 20; + const baseLine = `\x1b[3m${"X".repeat(width)}\x1b[23m`; + + const terminal = new VirtualTerminal(width, 6); + const tui = new TUI(terminal); + tui.addChild(new StaticLines([baseLine, "INPUT"])); + + tui.showOverlay(new StaticOverlay("OVR"), { row: 0, col: 5, width: 3 }); + tui.start(); + await renderAndFlush(tui, terminal); + + assert.strictEqual(getCellItalic(terminal, 1, 0), 0); + tui.stop(); + }); +});