From f5de77a42622d13472d524680446503a40893ccf Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Wed, 27 May 2026 15:15:35 +0800 Subject: [PATCH] test: add frame-writer parity tests from Ink log-update (+41) Port all 35 test declarations from Ink's log-update test suite, producing 41 new runtime tests (6 cursor tests run in both standard and incremental modes via describe.each). Covers standard rendering, incremental rendering (surgical updates, shrink, grow), clear/done reset, sync+update, cursor positioning, no-trailing-newline fullscreen mode, and render-to-empty. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/runtime/src/io/frame-writer.test.ts | 625 ++++++++++++++++++- 1 file changed, 624 insertions(+), 1 deletion(-) diff --git a/packages/runtime/src/io/frame-writer.test.ts b/packages/runtime/src/io/frame-writer.test.ts index 7e758bb..e4e6861 100644 --- a/packages/runtime/src/io/frame-writer.test.ts +++ b/packages/runtime/src/io/frame-writer.test.ts @@ -1,6 +1,61 @@ import { PassThrough } from "node:stream"; -import { expect, test } from "vite-plus/test"; +import { EventEmitter } from "node:events"; +import ansiEscapes from "ansi-escapes"; +import { describe, expect, test } from "vite-plus/test"; import { createFrameWriter } from "./frame-writer.ts"; +import logUpdate from "./log-update.ts"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +interface WriteSpy { + (...args: unknown[]): boolean; + callCount: number; + firstCall: { args: unknown[] }; + secondCall: { args: unknown[] }; + calls: unknown[][]; +} + +interface FakeStdout extends NodeJS.WriteStream { + /** Last write arg */ + get: () => string; + write: WriteSpy; +} + +function createStdout(): FakeStdout { + const stdout = new EventEmitter() as unknown as FakeStdout; + stdout.columns = 100; + stdout.isTTY = true; + + const calls: unknown[][] = []; + const writeFn = ((...args: unknown[]) => { + calls.push(args); + writeFn.callCount = calls.length; + return true; + }) as WriteSpy; + + writeFn.callCount = 0; + writeFn.calls = calls; + + Object.defineProperty(writeFn, "firstCall", { + get: () => ({ args: calls[0] ?? [] }), + }); + Object.defineProperty(writeFn, "secondCall", { + get: () => ({ args: calls[1] ?? [] }), + }); + + stdout.write = writeFn; + stdout.get = () => calls[calls.length - 1]![0] as string; + return stdout; +} + +const showCursorEscape = "[?25h"; +const hideCursorEscape = "[?25l"; + +// --------------------------------------------------------------------------- +// Debug mode (existing test) +// --------------------------------------------------------------------------- test("debug mode writes complete frames terminated by newline", () => { const writes: string[] = []; @@ -15,3 +70,571 @@ test("debug mode writes complete frames terminated by newline", () => { expect(writes).toEqual(["hello\n", "world\n"]); }); + +// --------------------------------------------------------------------------- +// Standard rendering +// --------------------------------------------------------------------------- + +describe("standard rendering", () => { + test("renders and updates output", () => { + const stdout = createStdout(); + const render = logUpdate.create(stdout, { showCursor: true }); + + render("Hello\n"); + expect(stdout.write.callCount).toBe(1); + expect(stdout.write.firstCall.args[0]).toBe("Hello\n"); + + render("World\n"); + expect(stdout.write.callCount).toBe(2); + expect((stdout.write.secondCall.args[0] as string).includes("World")).toBe(true); + }); + + test("skips identical output", () => { + const stdout = createStdout(); + const render = logUpdate.create(stdout, { showCursor: true }); + + render("Hello\n"); + render("Hello\n"); + + expect(stdout.write.callCount).toBe(1); + }); + + test("positions cursor after output when cursorPosition is set", () => { + const stdout = createStdout(); + const render = logUpdate.create(stdout, { showCursor: true }); + + render.setCursorPosition({ x: 5, y: 1 }); + render("Line 1\nLine 2\nLine 3\n"); + + const written = stdout.write.firstCall.args[0] as string; + expect(written.includes("Line 3")).toBe(true); + expect( + written.endsWith(ansiEscapes.cursorUp(2) + ansiEscapes.cursorTo(5) + showCursorEscape), + ).toBe(true); + }); + + test("hides cursor before erase when cursor was previously shown", () => { + const stdout = createStdout(); + const render = logUpdate.create(stdout, { showCursor: true }); + + render.setCursorPosition({ x: 0, y: 0 }); + render("Hello\n"); + render.setCursorPosition({ x: 0, y: 0 }); + render("World\n"); + + const secondCall = stdout.write.secondCall.args[0] as string; + expect(secondCall.startsWith(hideCursorEscape)).toBe(true); + expect( + secondCall.endsWith(ansiEscapes.cursorUp(1) + ansiEscapes.cursorTo(0) + showCursorEscape), + ).toBe(true); + }); + + test("no cursor positioning when cursorPosition is undefined", () => { + const stdout = createStdout(); + const render = logUpdate.create(stdout, { showCursor: true }); + + render("Hello\n"); + + const written = stdout.write.firstCall.args[0] as string; + expect(written.includes(showCursorEscape)).toBe(false); + }); + + test("cursor position at second-to-last line emits cursorUp(1)", () => { + const stdout = createStdout(); + const render = logUpdate.create(stdout, { showCursor: true }); + + render.setCursorPosition({ x: 3, y: 2 }); + render("Line 1\nLine 2\nLine 3\n"); + + const written = stdout.write.firstCall.args[0] as string; + expect( + written.endsWith(ansiEscapes.cursorUp(1) + ansiEscapes.cursorTo(3) + showCursorEscape), + ).toBe(true); + }); + + test("clearing cursor position stops cursor positioning", () => { + const stdout = createStdout(); + const render = logUpdate.create(stdout, { showCursor: true }); + + render.setCursorPosition({ x: 0, y: 0 }); + render("Hello\n"); + + render.setCursorPosition(undefined); + render("World\n"); + + const secondCall = stdout.write.secondCall.args[0] as string; + expect(secondCall.includes(showCursorEscape)).toBe(false); + }); + + test("returns to bottom before erase when cursor was positioned", () => { + const stdout = createStdout(); + const render = logUpdate.create(stdout, { showCursor: true }); + + render.setCursorPosition({ x: 0, y: 0 }); + render("Line 1\nLine 2\nLine 3\n"); + + render.setCursorPosition({ x: 5, y: 0 }); + render("Line A\nLine B\nLine C\n"); + + const secondCall = stdout.write.secondCall.args[0] as string; + expect(secondCall.startsWith(hideCursorEscape)).toBe(true); + expect(secondCall.includes(ansiEscapes.cursorDown(3))).toBe(true); + expect(secondCall.includes("Line A")).toBe(true); + }); + + test("sync() without cursor does not write to stream", () => { + const stdout = createStdout(); + const render = logUpdate.create(stdout, { showCursor: true }); + + render.sync("Line 1\nLine 2\nLine 3\n"); + + expect(stdout.write.callCount).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// Incremental rendering +// --------------------------------------------------------------------------- + +describe("incremental rendering", () => { + test("renders and updates output", () => { + const stdout = createStdout(); + const render = logUpdate.create(stdout, { + showCursor: true, + incremental: true, + }); + + render("Hello\n"); + expect(stdout.write.callCount).toBe(1); + expect(stdout.write.firstCall.args[0]).toBe("Hello\n"); + + render("World\n"); + expect(stdout.write.callCount).toBe(2); + expect((stdout.write.secondCall.args[0] as string).includes("World")).toBe(true); + }); + + test("skips identical output", () => { + const stdout = createStdout(); + const render = logUpdate.create(stdout, { + showCursor: true, + incremental: true, + }); + + render("Hello\n"); + render("Hello\n"); + + expect(stdout.write.callCount).toBe(1); + }); + + test("surgical updates", () => { + const stdout = createStdout(); + const render = logUpdate.create(stdout, { + showCursor: true, + incremental: true, + }); + + render("Line 1\nLine 2\nLine 3\n"); + render("Line 1\nUpdated\nLine 3\n"); + + const secondCall = stdout.write.secondCall.args[0] as string; + expect(secondCall.includes(ansiEscapes.cursorNextLine)).toBe(true); + expect(secondCall.includes("Updated")).toBe(true); + expect(secondCall.includes("Line 1")).toBe(false); + expect(secondCall.includes("Line 3")).toBe(false); + }); + + test("same-height update rewinds cursor to top with trailing newline", () => { + const stdout = createStdout(); + const render = logUpdate.create(stdout, { + showCursor: true, + incremental: true, + }); + + render("Line 1\nLine 2\nLine 3\n"); + render("Line 1\nUpdated\nLine 3\n"); + + const secondCall = stdout.write.secondCall.args[0] as string; + expect(secondCall.startsWith(ansiEscapes.cursorUp(3))).toBe(true); + }); + + test("clears extra lines when output shrinks", () => { + const stdout = createStdout(); + const render = logUpdate.create(stdout, { + showCursor: true, + incremental: true, + }); + + render("Line 1\nLine 2\nLine 3\n"); + render("Line 1\n"); + + const secondCall = stdout.write.secondCall.args[0] as string; + expect(secondCall.includes(ansiEscapes.eraseLines(2))).toBe(true); + }); + + test("when output grows", () => { + const stdout = createStdout(); + const render = logUpdate.create(stdout, { + showCursor: true, + incremental: true, + }); + + render("Line 1\n"); + render("Line 1\nLine 2\nLine 3\n"); + + const secondCall = stdout.write.secondCall.args[0] as string; + expect(secondCall.includes(ansiEscapes.cursorNextLine)).toBe(true); + expect(secondCall.includes("Line 2")).toBe(true); + expect(secondCall.includes("Line 3")).toBe(true); + expect(secondCall.includes("Line 1")).toBe(false); + }); + + test("single write call with multiple surgical updates", () => { + const stdout = createStdout(); + const render = logUpdate.create(stdout, { + showCursor: true, + incremental: true, + }); + + render("Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8\nLine 9\nLine 10\n"); + render( + "Line 1\nUpdated 2\nLine 3\nUpdated 4\nLine 5\nUpdated 6\nLine 7\nUpdated 8\nLine 9\nUpdated 10\n", + ); + + expect(stdout.write.callCount).toBe(2); + }); + + test("shrinking output keeps screen tight", () => { + const stdout = createStdout(); + const render = logUpdate.create(stdout, { + showCursor: true, + incremental: true, + }); + + render("Line 1\nLine 2\nLine 3\n"); + render("Line 1\nLine 2\n"); + render("Line 1\n"); + + const thirdCall = stdout.get(); + + expect(thirdCall).toBe( + ansiEscapes.eraseLines(2) + ansiEscapes.cursorUp(1) + ansiEscapes.cursorNextLine, + ); + }); + + test("clear() fully resets incremental state", () => { + const stdout = createStdout(); + const render = logUpdate.create(stdout, { + showCursor: true, + incremental: true, + }); + + render("Line 1\nLine 2\nLine 3\n"); + render.clear(); + render("Line 1\n"); + + const afterClear = stdout.get(); + + expect(afterClear).toBe(ansiEscapes.eraseLines(0) + "Line 1\n"); + }); + + test("done() resets before next render", () => { + const stdout = createStdout(); + const render = logUpdate.create(stdout, { + showCursor: true, + incremental: true, + }); + + render("Line 1\nLine 2\nLine 3\n"); + render.done(); + render("Line 1\n"); + + const afterDone = stdout.get(); + + expect(afterDone).toBe(ansiEscapes.eraseLines(0) + "Line 1\n"); + }); + + test("multiple consecutive clear() calls (should be harmless no-ops)", () => { + const stdout = createStdout(); + const render = logUpdate.create(stdout, { + showCursor: true, + incremental: true, + }); + + render("Line 1\nLine 2\nLine 3\n"); + render.clear(); + render.clear(); + render.clear(); + + expect(stdout.write.callCount).toBe(4); + + render("New content\n"); + const afterClears = stdout.get(); + expect(afterClears).toBe(ansiEscapes.eraseLines(0) + "New content\n"); + }); + + test("sync() followed by update (assert incremental path is used)", () => { + const stdout = createStdout(); + const render = logUpdate.create(stdout, { + showCursor: true, + incremental: true, + }); + + render.sync("Line 1\nLine 2\nLine 3\n"); + expect(stdout.write.callCount).toBe(0); + + render("Line 1\nUpdated\nLine 3\n"); + expect(stdout.write.callCount).toBe(1); + + const firstCall = stdout.write.firstCall.args[0] as string; + expect(firstCall.includes(ansiEscapes.cursorNextLine)).toBe(true); + expect(firstCall.includes("Updated")).toBe(true); + expect(firstCall.includes("Line 1")).toBe(false); + expect(firstCall.includes("Line 3")).toBe(false); + }); + + test("positions cursor after surgical updates", () => { + const stdout = createStdout(); + const render = logUpdate.create(stdout, { + showCursor: true, + incremental: true, + }); + + render.setCursorPosition({ x: 5, y: 1 }); + render("Line 1\nLine 2\nLine 3\n"); + + const written = stdout.write.firstCall.args[0] as string; + expect( + written.endsWith(ansiEscapes.cursorUp(2) + ansiEscapes.cursorTo(5) + showCursorEscape), + ).toBe(true); + }); + + test("positions cursor after update", () => { + const stdout = createStdout(); + const render = logUpdate.create(stdout, { + showCursor: true, + incremental: true, + }); + + render.setCursorPosition({ x: 2, y: 0 }); + render("Line 1\nLine 2\nLine 3\n"); + render.setCursorPosition({ x: 2, y: 0 }); + render("Line 1\nUpdated\nLine 3\n"); + + const secondCall = stdout.write.secondCall.args[0] as string; + expect( + secondCall.endsWith(ansiEscapes.cursorUp(3) + ansiEscapes.cursorTo(2) + showCursorEscape), + ).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Cursor positioning (both rendering modes) +// --------------------------------------------------------------------------- + +const modes = [ + { name: "standard", incremental: false }, + { name: "incremental", incremental: true }, +] as const; + +function createRenderForMode(incremental: boolean) { + const stdout = createStdout(); + const render = logUpdate.create(stdout, { + showCursor: true, + incremental, + }); + return { stdout, render }; +} + +describe.each(modes)("$name mode - cursor positioning", ({ incremental }) => { + test("clear() returns cursor to bottom before erasing", () => { + const { stdout, render } = createRenderForMode(incremental); + + render.setCursorPosition({ x: 5, y: 0 }); + render("Line 1\nLine 2\nLine 3\n"); + + render.clear(); + + const clearCall = stdout.write.secondCall.args[0] as string; + expect(clearCall.includes(hideCursorEscape)).toBe(true); + expect(clearCall.includes(ansiEscapes.cursorDown(3))).toBe(true); + expect(clearCall.includes(ansiEscapes.eraseLines(4))).toBe(true); + }); + + test("repositions cursor when only cursor position changes (same output)", () => { + const { stdout, render } = createRenderForMode(incremental); + + render.setCursorPosition({ x: 2, y: 0 }); + render("Hello\n"); + expect(stdout.write.callCount).toBe(1); + + render.setCursorPosition({ x: 3, y: 0 }); + render("Hello\n"); + + expect(stdout.write.callCount).toBe(2); + const secondCall = stdout.write.secondCall.args[0] as string; + expect(secondCall.includes(showCursorEscape)).toBe(true); + expect(secondCall.endsWith(ansiEscapes.cursorTo(3) + showCursorEscape)).toBe(true); + }); + + test("sync() resets cursor state", () => { + const { stdout, render } = createRenderForMode(incremental); + + render.setCursorPosition({ x: 5, y: 0 }); + render("Line 1\nLine 2\nLine 3\n"); + + render.sync("Fresh output\n"); + + render("Updated output\n"); + + const afterSync = stdout.get(); + expect(afterSync.includes(hideCursorEscape)).toBe(false); + expect(afterSync.includes(ansiEscapes.cursorDown(3))).toBe(false); + }); + + test("sync() writes cursor suffix when cursor is dirty", () => { + const { stdout, render } = createRenderForMode(incremental); + + render.setCursorPosition({ x: 5, y: 1 }); + render.sync("Line 1\nLine 2\nLine 3\n"); + + expect(stdout.write.callCount).toBe(1); + const written = stdout.write.firstCall.args[0] as string; + expect(written).toBe(ansiEscapes.cursorUp(2) + ansiEscapes.cursorTo(5) + showCursorEscape); + }); + + test("sync() with cursor sets cursorWasShown for next render", () => { + const { stdout, render } = createRenderForMode(incremental); + + render.setCursorPosition({ x: 5, y: 1 }); + render.sync("Line 1\nLine 2\nLine 3\n"); + + render("Updated\n"); + + const renderCall = stdout.get(); + expect(renderCall.startsWith(hideCursorEscape)).toBe(true); + }); + + test("sync() hides cursor when previous render showed cursor", () => { + const { stdout, render } = createRenderForMode(incremental); + + render.setCursorPosition({ x: 5, y: 1 }); + render("Line 1\nLine 2\nLine 3\n"); + expect(stdout.write.callCount).toBe(1); + + render.sync("Fresh output\n"); + + expect(stdout.write.callCount).toBe(2); + expect(stdout.write.secondCall.args[0] as string).toBe(hideCursorEscape); + }); +}); + +// --------------------------------------------------------------------------- +// No-trailing-newline tests (fullscreen mode) +// --------------------------------------------------------------------------- + +describe("incremental rendering - no trailing newline (fullscreen)", () => { + test("trailing to no-trailing transition", () => { + const stdout = createStdout(); + const render = logUpdate.create(stdout, { + showCursor: true, + incremental: true, + }); + + render("A\nB\n"); + render("A\nB"); + + const secondCall = stdout.write.secondCall.args[0] as string; + expect(secondCall.includes(ansiEscapes.cursorNextLine)).toBe(true); + expect(secondCall.endsWith("\n")).toBe(false); + }); + + test("no-trailing to no-trailing update", () => { + const stdout = createStdout(); + const render = logUpdate.create(stdout, { + showCursor: true, + incremental: true, + }); + + render("A\nB"); + render("A\nC"); + + const secondCall = stdout.write.secondCall.args[0] as string; + expect(secondCall.includes(ansiEscapes.cursorNextLine)).toBe(true); + expect(secondCall.includes("C")).toBe(true); + expect(secondCall.endsWith("\n")).toBe(false); + }); + + test("shrink", () => { + const stdout = createStdout(); + const render = logUpdate.create(stdout, { + showCursor: true, + incremental: true, + }); + + render("A\nB"); + render("A"); + + const secondCall = stdout.write.secondCall.args[0] as string; + expect(secondCall.includes(ansiEscapes.eraseLines(1))).toBe(true); + expect(secondCall.endsWith("\n")).toBe(false); + }); + + test("grow", () => { + const stdout = createStdout(); + const render = logUpdate.create(stdout, { + showCursor: true, + incremental: true, + }); + + render("A"); + render("A\nB\nC"); + + const secondCall = stdout.write.secondCall.args[0] as string; + expect(secondCall.includes("B")).toBe(true); + expect(secondCall.includes("C")).toBe(true); + expect(secondCall.endsWith("\n")).toBe(false); + }); + + test("unchanged lines do not overshoot cursor", () => { + const stdout = createStdout(); + const render = logUpdate.create(stdout, { + showCursor: true, + incremental: true, + }); + + render("A\nB"); + render("A\nB"); // identical - should be skipped entirely + + expect(stdout.write.callCount).toBe(1); + + render("X\nB"); + + const thirdCall = stdout.write.secondCall.args[0] as string; + expect(thirdCall.includes("X")).toBe(true); + const lastCursorNextLine = thirdCall.lastIndexOf(ansiEscapes.cursorNextLine); + expect(lastCursorNextLine).toBe(-1); + }); +}); + +// --------------------------------------------------------------------------- +// Render to empty string +// --------------------------------------------------------------------------- + +test("incremental rendering - render to empty string (full clear vs early exit)", () => { + const stdout = createStdout(); + const render = logUpdate.create(stdout, { + showCursor: true, + incremental: true, + }); + + render("Line 1\nLine 2\nLine 3\n"); + render("\n"); + + expect(stdout.write.callCount).toBe(2); + const secondCall = stdout.write.secondCall.args[0] as string; + expect(secondCall).toBe(ansiEscapes.eraseLines(4) + "\n"); + + // Rendering empty string again should be skipped (identical output) + render("\n"); + expect(stdout.write.callCount).toBe(2); +});