// G11 parity: clear+reset on terminal-width DECREASE during resize. // These tests use interactive mode (not debug) so that writer.clear() emits // real ANSI erase sequences to stdout. They do NOT use fake timers and are // safe to run concurrently with other test files. import { defineComponent, nextTick } from "vue"; import { expect, test } from "vite-plus/test"; import { createApp, Box, Text } from "@vue-tui/runtime"; import stripAnsi from "strip-ansi"; import { makeFakeWritable, makeFakeStdin, captureWrites, getContentWrites, } from "./test-streams.ts"; // eraseLines(n) emits n repetitions of "\x1b[2K" (erase line) — the sequence // that log-update's clear() writes to wipe the previous rendered frame before // repainting. We treat the presence of "\x1b[2K" in the output bytes collected // AFTER the resize event as proof that writer.clear() ran on width decrease. const ERASE_LINE = "\x1b[2K"; async function mountInteractive(columns: number, rows: number) { const stdout = makeFakeWritable({ columns, rows }); const stderr = makeFakeWritable({ columns: columns, rows }); const { stream: stdin } = makeFakeStdin(); const writes = captureWrites(stdout); const App = defineComponent(() => { return () => hello; }); const app = createApp(App); // interactive: true forces the resize handler even in CI where isTTY alone // would disable it. exitOnCtrlC: false so the app stays alive. app.mount({ stdout, stdin, stderr, interactive: true, exitOnCtrlC: false }); // Flush initial render. await nextTick(); await nextTick(); return { app, stdout, writes }; } test("width decrease triggers writer.clear() — erase sequence in output", async () => { const { app, stdout, writes } = await mountInteractive(100, 24); // Baseline: snapshot the write count so we can inspect INCREMENTAL output // after the resize. log-update hides the cursor on the first write // (\x1b[?25l) but does not erase lines — no \x1b[2K before narrowing. const writeCountBefore = writes.length; // Narrow the terminal: 100 → 60 columns. This is a width DECREASE. stdout.columns = 60; stdout.emit("resize"); await nextTick(); await nextTick(); // Collect only the bytes written DURING/AFTER the resize event. const writesAfterResize = writes.slice(writeCountBefore).join(""); // Must contain the erase-line sequence from writer.clear() (Ink parity G11). expect(writesAfterResize).toContain(ERASE_LINE); app.unmount(); }); test("width increase does NOT trigger extra writer.clear()", async () => { const { app, stdout, writes } = await mountInteractive(60, 24); const writeCountBefore = writes.length; // Widen the terminal: 60 → 100 columns. NOT a narrowing — no clear expected. stdout.columns = 100; stdout.emit("resize"); await nextTick(); await nextTick(); const writesAfterResize = writes.slice(writeCountBefore).join(""); // A width-increase resize should NOT emit the erase-line sequence // (the normal log-update diff path handles it without a full clear). expect(writesAfterResize).not.toContain(ERASE_LINE); app.unmount(); }); test("equal-width resize (pure height change) does NOT trigger extra writer.clear()", async () => { const { app, stdout, writes } = await mountInteractive(80, 24); const writeCountBefore = writes.length; // Same columns, different rows — pure height change, no narrowing. stdout.rows = 40; stdout.emit("resize"); await nextTick(); await nextTick(); const writesAfterResize = writes.slice(writeCountBefore).join(""); // Height-only resize must not trigger the width-decrease clear path. expect(writesAfterResize).not.toContain(ERASE_LINE); app.unmount(); }); // clearTerminal sequence emitted by ansiEscapes.clearTerminal (\x1b[2J\x1b[3J\x1b[H). // shouldClearTerminalForFrame fires this when wasOverflowing (previousOutputHeight > rows) // or when isOverflowing && hadPreviousFrame. Zeroing outputHeight on narrowing (the bug) // makes hadPreviousFrame=false and wasOverflowing=false, suppressing the clear. const CLEAR_TERMINAL = "\x1b[2J"; test("narrowing when previous frame overflows terminal fires clearTerminal (not just erase-lines)", async () => { // Use a tiny terminal (3 rows) so a 5-line frame overflows (height > rows). // The overflowing previous frame sets outputHeight=5; on narrowing the resize // handler must NOT zero it — wasOverflowing=true → clearTerminal fires. const stdout = makeFakeWritable({ columns: 80, rows: 3 }); const stderr = makeFakeWritable({ columns: 80, rows: 3 }); const { stream: stdin } = makeFakeStdin(); const writes = captureWrites(stdout); // Render 5 lines so outputHeight (5) exceeds rows (3) → overflowing frame. // Box with flexDirection column stacks Text children into separate rows. const App = defineComponent(() => { return () => ( line one line two line three line four line five ); }); const app = createApp(App); app.mount({ stdout, stdin, stderr, interactive: true, exitOnCtrlC: false }); // Flush initial render — this sets outputHeight = 5. await nextTick(); await nextTick(); const writeCountBefore = writes.length; // Narrow from 80 → 40 columns. The previous frame was overflowing (height=5 > rows=3), // so wasOverflowing=true → shouldClearTerminalForFrame must return true → // clearTerminal (\x1b[2J) must appear in the output. stdout.columns = 40; stdout.emit("resize"); await nextTick(); await nextTick(); const writesAfterResize = writes.slice(writeCountBefore).join(""); // Must contain clearTerminal (the \x1b[2J part of ansiEscapes.clearTerminal). // With the outputHeight=0 bug this is suppressed (hadPreviousFrame=false, // wasOverflowing=false); with the fix (height preserved) wasOverflowing=true fires it. expect(writesAfterResize).toContain(CLEAR_TERMINAL); app.unmount(); }); // Port of Ink render.tsx:814-847 ("rerender on resize"): a resize doesn't just // erase the old frame — it REFLOWS the content to the new width. A round-border // wrapping "Test" renders its top/bottom rule to fill the inner width, so // the exact bytes of the first content write must be the box padded to width-10, // and the LAST content write after narrowing to 8 must be the box re-padded to // width-8. (Ink asserts boxen('Test'.padEnd(8)) then boxen('Test'.padEnd(6)).) test("resize reflows content — first frame is width-10 box, last is re-padded to width-8", async () => { const stdout = makeFakeWritable({ columns: 10, rows: 100 }); const stderr = makeFakeWritable({ columns: 10, rows: 100 }); const { stream: stdin } = makeFakeStdin(); const writes = captureWrites(stdout); const App = defineComponent(() => () => ( Test )); const app = createApp(App); app.mount({ stdout, stdin, stderr, interactive: true, exitOnCtrlC: false }); await nextTick(); await nextTick(); // First content write: round-border box whose inner content row is "Test" // padded to the 8-cell inner width (columns 10 minus the 2 border columns), // matching Ink's boxen('Test'.padEnd(8), {borderStyle: 'round'}) + '\n'. const contentWrites = getContentWrites(writes); const width10Box = "╭────────╮\n│Test │\n╰────────╯\n"; expect(stripAnsi(contentWrites[0]!)).toBe(width10Box); // Narrow 10 → 8 columns. The frame must reflow: inner width drops to 6. stdout.columns = 8; stdout.emit("resize"); await nextTick(); await nextTick(); // Last content write after resize: the SAME box re-padded to the new width // (Ink's boxen('Test'.padEnd(6), {borderStyle: 'round'}) + '\n'). const width8Box = "╭──────╮\n│Test │\n╰──────╯\n"; const contentWritesAfter = getContentWrites(writes); expect(stripAnsi(contentWritesAfter.at(-1)!)).toBe(width8Box); app.unmount(); }); // Port of Ink terminal-resize.tsx:211-246: consecutive width DECREASES must EACH // clear. After narrowing 100→80 (clears) and then 80→60, the second narrowing // must clear AGAIN — proving lastTerminalWidth advanced to 80 (so 60 < 80 is a // fresh decrease), rather than being stuck at the original 100. test("consecutive width decreases each emit an erase sequence", async () => { const { app, stdout, writes } = await mountInteractive(100, 24); // First narrowing: 100 → 80. let before = writes.length; stdout.columns = 80; stdout.emit("resize"); await nextTick(); await nextTick(); expect(writes.slice(before).join("")).toContain(ERASE_LINE); // Second narrowing: 80 → 60. If lastTerminalWidth were stuck at 100, the // handler would still see a decrease here too — but the discriminating case // is that 60 < 80 (the UPDATED width) is itself a decrease and clears again. before = writes.length; stdout.columns = 60; stdout.emit("resize"); await nextTick(); await nextTick(); expect(writes.slice(before).join("")).toContain(ERASE_LINE); app.unmount(); });