Files
vue-tui/packages/runtime/src/io/frame-writer.test.ts
T
Yunfei He 7d03a110e1 fix: address /simplify review — frame-writer dedup desync, scheduler edge cases
Correctness fixes found by max-effort review of the branch diff:

- frame-writer.sync() now updates lastFrame alongside log-update's
  previousOutput. Previously the two dedup layers desynced after a sync()
  (the clearTerminal path), silently dropping a legitimately-changed frame
  and emitting an empty BSU/ESU pair. Adds a regression test.
- scheduler: the queuePostFlushCb callback now bails if scheduled was reset
  by cancel(), so a stale callback can't commit on a torn-down tree or
  re-arm an uncancellable trailing timer.
- scheduler.flush() now collects multiple concurrent waiters instead of
  overwriting a single resolver — fixes a hang when two waitUntilRenderFlush()
  calls await the same pending commit.
- render teardown nulls mountedClear so a post-unmount app.clear() can't
  write to a torn-down stream.
- test-streams getContentWrites imports bsu/esu instead of hardcoding the
  escape literals.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 14:18:23 +08:00

801 lines
26 KiB
TypeScript

import { PassThrough } from "node:stream";
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 { showCursorEscape, hideCursorEscape } from "./cursor-helpers.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;
}
// ---------------------------------------------------------------------------
// Debug mode (existing test)
// ---------------------------------------------------------------------------
test("debug mode writes complete frames terminated by newline", () => {
const writes: string[] = [];
const stream = new PassThrough() as unknown as NodeJS.WriteStream;
Object.assign(stream, { columns: 80, rows: 24, isTTY: true });
stream.on("data", (chunk) => writes.push(chunk.toString()));
const writer = createFrameWriter(stream, { debug: true });
writer.write("hello");
writer.write("hello"); // identical frame skipped
writer.write("world");
expect(writes).toEqual(["hello\n", "world\n"]);
});
test("sync() updates the dedup baseline so a later changed frame is not dropped", () => {
// Regression: sync() previously updated log-update's previousOutput but not
// the frame-writer's own lastFrame. After a sync() (e.g. the clearTerminal
// path), re-rendering the pre-sync frame was silently dropped by the stale
// lastFrame dedup even though the terminal showed different content.
const writes: string[] = [];
const stream = new PassThrough() as unknown as NodeJS.WriteStream;
Object.assign(stream, { columns: 80, rows: 24, isTTY: true });
stream.on("data", (chunk) => writes.push(chunk.toString()));
const writer = createFrameWriter(stream, {});
writer.write("A\n"); // lastFrame = "A\n"
const countAfterA = writes.length;
// Simulate the shouldClear path: terminal is repainted to "B" out-of-band
// and the writer is synced to that new baseline.
writer.sync("B\n");
// Re-render "A": content differs from what the terminal now shows ("B"),
// so it MUST be emitted, not skipped by a stale lastFrame === "A\n".
writer.write("A\n");
expect(writes.length).toBeGreaterThan(countAfterA);
expect(writes.some((w) => w.includes("A"))).toBe(true);
});
// ---------------------------------------------------------------------------
// 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);
});
// ---------------------------------------------------------------------------
// createFrameWriter() integration tests
//
// Unlike the logUpdate tests above (which pass showCursor: true),
// createFrameWriter does NOT set showCursor — so logUpdate will emit a
// hideCursor escape on the first render. Assertions here use `stdout.get()`
// (last written chunk) and relative call-count deltas rather than absolute
// counts to stay independent of that detail.
// ---------------------------------------------------------------------------
describe("createFrameWriter - standard mode", () => {
test("write renders output and skips identical frames", () => {
const stdout = createStdout();
const writer = createFrameWriter(stdout, {});
writer.write("Hello\n");
const countAfterFirst = stdout.write.callCount;
// The last write must contain the frame content
expect(stdout.get().includes("Hello")).toBe(true);
// Identical frame is skipped at the wrapper level
writer.write("Hello\n");
expect(stdout.write.callCount).toBe(countAfterFirst);
// Different frame is rendered
writer.write("World\n");
expect(stdout.write.callCount).toBeGreaterThan(countAfterFirst);
expect(stdout.get().includes("World")).toBe(true);
});
test("clear() resets dedup so the same frame renders again", () => {
const stdout = createStdout();
const writer = createFrameWriter(stdout, {});
writer.write("Hello\n");
const countAfterFirst = stdout.write.callCount;
writer.clear();
const countAfterClear = stdout.write.callCount;
expect(countAfterClear).toBeGreaterThanOrEqual(countAfterFirst);
// After clear(), the same content should render again
writer.write("Hello\n");
expect(stdout.write.callCount).toBeGreaterThan(countAfterClear);
expect(stdout.get().includes("Hello")).toBe(true);
});
test("done() persists output and resets for next render", () => {
const stdout = createStdout();
const writer = createFrameWriter(stdout, {});
writer.write("Line 1\nLine 2\n");
writer.done();
const countAfterDone = stdout.write.callCount;
// After done(), writing new content should work
writer.write("New content\n");
expect(stdout.write.callCount).toBeGreaterThan(countAfterDone);
expect(stdout.get().includes("New content")).toBe(true);
});
});
describe("createFrameWriter - incremental mode", () => {
test("write renders output and updates incrementally", () => {
const stdout = createStdout();
const writer = createFrameWriter(stdout, { incremental: true });
writer.write("Line 1\nLine 2\nLine 3\n");
const countAfterFirst = stdout.write.callCount;
writer.write("Line 1\nUpdated\nLine 3\n");
expect(stdout.write.callCount).toBeGreaterThan(countAfterFirst);
const lastWritten = stdout.get();
expect(lastWritten.includes("Updated")).toBe(true);
// Incremental: unchanged lines are not re-sent
expect(lastWritten.includes("Line 1")).toBe(false);
expect(lastWritten.includes("Line 3")).toBe(false);
});
test("write skips identical frames", () => {
const stdout = createStdout();
const writer = createFrameWriter(stdout, { incremental: true });
writer.write("Hello\n");
const countAfterFirst = stdout.write.callCount;
writer.write("Hello\n");
// Dedup at the wrapper level prevents even reaching logUpdate
expect(stdout.write.callCount).toBe(countAfterFirst);
});
});
describe("createFrameWriter - clear/done behavior", () => {
test("clear() erases output and allows re-render of same content", () => {
const stdout = createStdout();
const writer = createFrameWriter(stdout, { incremental: true });
writer.write("Line 1\nLine 2\n");
const countAfterWrite = stdout.write.callCount;
writer.clear();
const countAfterClear = stdout.write.callCount;
// clear() triggers an erase write
expect(countAfterClear).toBeGreaterThan(countAfterWrite);
// Same content should render again after clear()
writer.write("Line 1\nLine 2\n");
expect(stdout.write.callCount).toBeGreaterThan(countAfterClear);
});
test("done() preserves output and resets dedup state", () => {
const stdout = createStdout();
const writer = createFrameWriter(stdout, { incremental: true });
writer.write("First\n");
writer.done();
const countAfterDone = stdout.write.callCount;
// After done(), a new frame goes through
writer.write("Second\n");
expect(stdout.write.callCount).toBeGreaterThan(countAfterDone);
expect(stdout.get().includes("Second")).toBe(true);
});
test("willRender returns false for identical frames", () => {
const stdout = createStdout();
const writer = createFrameWriter(stdout, {});
writer.write("Hello\n");
// willRender delegates to logUpdate; identical content returns false
expect(writer.willRender("Hello\n")).toBe(false);
// Different content returns true
expect(writer.willRender("World\n")).toBe(true);
});
});