// Sequential: uses vi.useFakeTimers, which mutates the process-global timer // functions. Run concurrently, a sibling test calling useRealTimers() would // pull the mocked timers out mid-advanceTimersByTime. Tests are it.sequential. import { defineComponent, nextTick, shallowRef } from "vue"; import { expect, test, vi } from "vite-plus/test"; import { Box, createApp, Text } from "@vue-tui/runtime"; import ansiEscapes from "ansi-escapes"; import stripAnsi from "strip-ansi"; import { makeFakeStdin, makeFakeWritable, captureWrites, getContentWrites, } from "./test-streams.ts"; // Fake timer options: only fake setTimeout/clearTimeout/Date so that // Vue's internal scheduler (nextTick, queueMicrotask, setImmediate) still // runs on real clocks. This matches the pattern in use-animation.test.tsx. const FAKE_TIMER_OPTS = { shouldAdvanceTime: false, toFake: ["setTimeout", "clearTimeout", "Date"] as ("setTimeout" | "clearTimeout" | "Date")[], }; test.sequential("throttle renders to maxFps", async () => { // Port of Ink's "throttle renders to maxFps" — verifies leading+trailing // throttle pattern with maxFps=1 (1000ms window). vi.useFakeTimers(FAKE_TIMER_OPTS); try { const msg = shallowRef("Hello"); const App = defineComponent(() => { return () => {msg.value}; }); const app = createApp(App); const stdout = makeFakeWritable({ columns: 80 }); const stderr = makeFakeWritable({ columns: 80 }); const { stream: stdin } = makeFakeStdin(); const writes = captureWrites(stdout); app.mount({ stdout, stdin, stderr, exitOnCtrlC: false, maxFps: 1, // 1 Hz => ~1000ms throttle window }); // Flush Vue scheduler so the initial commit fires (leading edge) await nextTick(); await nextTick(); const initialWriteCount = writes.length; expect(initialWriteCount).toBeGreaterThanOrEqual(1); expect(writes.some((w) => w.includes("Hello"))).toBe(true); // Trigger a rerender inside the throttle window msg.value = "World"; await nextTick(); await nextTick(); // Throttling should have held — no new writes yet const midWriteCount = writes.length; expect(midWriteCount).toBe(initialWriteCount); // Advance 999ms: still within window, no trailing call yet vi.advanceTimersByTime(999); expect(writes.length).toBe(midWriteCount); // Cross the 1000ms boundary: trailing render fires vi.advanceTimersByTime(1); expect(writes.some((w) => w.includes("World"))).toBe(true); app.unmount(); } finally { vi.useRealTimers(); } }); test.sequential("immediate scheduler in debug mode commits every mutation", async () => { // Counterpart to the throttle test: in debug mode (used by the testing // helper), the scheduler bypasses throttling and commits synchronously. // This verifies the immediate path in createCommitScheduler. const msg = shallowRef("A"); const App = defineComponent(() => { return () => {msg.value}; }); const app = createApp(App); const stdout = makeFakeWritable({ columns: 80 }); const stderr = makeFakeWritable({ columns: 80 }); const { stream: stdin } = makeFakeStdin(); const writes = captureWrites(stdout); app.mount({ stdout, stdin, stderr, debug: true, exitOnCtrlC: false, }); await nextTick(); await nextTick(); expect(writes.some((w) => w.includes("A"))).toBe(true); msg.value = "B"; await nextTick(); await nextTick(); expect(writes.some((w) => w.includes("B"))).toBe(true); msg.value = "C"; await nextTick(); await nextTick(); expect(writes.some((w) => w.includes("C"))).toBe(true); app.unmount(); }); test.sequential("screen reader mode bypasses throttle (immediate commits)", async () => { // Port of Ink's screen reader behavior: isScreenReaderEnabled causes // the scheduler to use immediate mode, ensuring every frame is flushed // without delay for assistive technology. const msg = shallowRef("Hello"); const App = defineComponent(() => { return () => {msg.value}; }); const app = createApp(App); const stdout = makeFakeWritable({ columns: 80 }); const stderr = makeFakeWritable({ columns: 80 }); const { stream: stdin } = makeFakeStdin(); const writes = captureWrites(stdout); app.mount({ stdout, stdin, stderr, exitOnCtrlC: false, isScreenReaderEnabled: true, }); await nextTick(); await nextTick(); expect(writes.some((w) => w.includes("Hello"))).toBe(true); // Even rapid mutations should commit immediately (no throttle) msg.value = "World"; await nextTick(); await nextTick(); expect(writes.some((w) => w.includes("World"))).toBe(true); app.unmount(); }); test.sequential("no throttled renders after unmount", async () => { vi.useFakeTimers(FAKE_TIMER_OPTS); try { const msg = shallowRef("Foo"); const App = defineComponent(() => () => {msg.value}); const app = createApp(App); const stdout = makeFakeWritable({ columns: 80 }); const stderr = makeFakeWritable({ columns: 80 }); const { stream: stdin } = makeFakeStdin(); const writes = captureWrites(stdout); app.mount({ stdout, stdin, stderr, exitOnCtrlC: false }); await nextTick(); await nextTick(); const initialCount = getContentWrites(writes).length; expect(initialCount).toBeGreaterThanOrEqual(1); msg.value = "Bar"; await nextTick(); msg.value = "Baz"; await nextTick(); app.unmount(); const countAfterUnmount = getContentWrites(writes).length; vi.advanceTimersByTime(1000); expect(getContentWrites(writes).length).toBe(countAfterUnmount); } finally { vi.useRealTimers(); } }); test.sequential("unmount forces pending throttled render", async () => { vi.useFakeTimers(FAKE_TIMER_OPTS); try { const msg = shallowRef("Hello"); const App = defineComponent(() => () => {msg.value}); const app = createApp(App); const stdout = makeFakeWritable({ columns: 80 }); const stderr = makeFakeWritable({ columns: 80 }); const { stream: stdin } = makeFakeStdin(); const writes = captureWrites(stdout); app.mount({ stdout, stdin, stderr, exitOnCtrlC: false, maxFps: 1 }); await nextTick(); await nextTick(); expect(getContentWrites(writes).length).toBe(1); expect(stripAnsi(getContentWrites(writes)[0]!)).toContain("Hello"); msg.value = "Final"; await nextTick(); await nextTick(); expect(getContentWrites(writes).length).toBe(1); app.unmount(); const allContent = getContentWrites(writes).map((w) => stripAnsi(w)); expect(allContent.some((c) => c.includes("Final"))).toBe(true); } finally { vi.useRealTimers(); } }); test.sequential("unmount cancels pending throttled log writes when stdout is ended", async () => { vi.useFakeTimers(FAKE_TIMER_OPTS); try { const { PassThrough } = await import("node:stream"); const stdout = new PassThrough() as unknown as NodeJS.WriteStream; stdout.columns = 100; const writeErrors: Error[] = []; stdout.on("error", (error: Error) => writeErrors.push(error)); const msg = shallowRef("Hello"); const App = defineComponent(() => () => {msg.value}); const app = createApp(App); const stderr = makeFakeWritable({ columns: 80 }); const { stream: stdin } = makeFakeStdin(); app.mount({ stdout, stdin, stderr, exitOnCtrlC: false, maxFps: 1 }); await nextTick(); await nextTick(); msg.value = "World"; await nextTick(); stdout.end(); app.unmount(); vi.advanceTimersByTime(1000); const hasWriteAfterEndError = writeErrors.some( (e) => (e as NodeJS.ErrnoException).code === "ERR_STREAM_WRITE_AFTER_END", ); expect(hasWriteAfterEndError).toBe(false); } finally { vi.useRealTimers(); } }); test.sequential("unmount cancels pending throttled render when stdout is ended", async () => { vi.useFakeTimers(FAKE_TIMER_OPTS); try { const { PassThrough } = await import("node:stream"); // Baseline: mount + end + unmount without pending rerender const baseStdout = new PassThrough() as unknown as NodeJS.WriteStream; baseStdout.columns = 100; const BaseApp = defineComponent(() => () => Hello); const baseApp = createApp(BaseApp); const baseStderr = makeFakeWritable({ columns: 80 }); const { stream: baseStdin } = makeFakeStdin(); baseApp.mount({ stdout: baseStdout, stdin: baseStdin, stderr: baseStderr, exitOnCtrlC: false, maxFps: 1, }); await nextTick(); await nextTick(); baseStdout.end(); baseApp.unmount(); const baselineTimers = vi.getTimerCount(); vi.runAllTimers(); // Test: mount + rerender + end + unmount const stdout = new PassThrough() as unknown as NodeJS.WriteStream; stdout.columns = 100; const msg = shallowRef("Hello"); const App = defineComponent(() => () => {msg.value}); const app = createApp(App); const stderr = makeFakeWritable({ columns: 80 }); const { stream: stdin } = makeFakeStdin(); app.mount({ stdout, stdin, stderr, exitOnCtrlC: false, maxFps: 1 }); await nextTick(); await nextTick(); msg.value = "World"; await nextTick(); stdout.end(); app.unmount(); expect(vi.getTimerCount()).toBe(baselineTimers); } finally { vi.useRealTimers(); } }); test.sequential("resize does not double-clear when a throttled commit is pending", async () => { // Regression for issue #26: the resize handler paints synchronously, but if // a trailing throttled commit is still pending, that timer fires a second // doCommit() right after. Because shouldClearTerminalForFrame clears whenever // the previous frame overflowed the viewport, the second commit emits a // duplicate clearTerminal. onResize must cancel the pending commit first. vi.useFakeTimers(FAKE_TIMER_OPTS); try { const msg = shallowRef("A"); // Three rows of content into a 2-row viewport => the frame overflows, so // every commit after the first takes the clearTerminal branch. const App = defineComponent(() => () => ( line1 line2 {msg.value} )); const app = createApp(App); const stdout = makeFakeWritable({ columns: 80, rows: 2 }); const stderr = makeFakeWritable({ columns: 80, rows: 2 }); const { stream: stdin } = makeFakeStdin(); const writes = captureWrites(stdout); app.mount({ stdout, stdin, stderr, exitOnCtrlC: false, interactive: true, maxFps: 1 }); await nextTick(); await nextTick(); const countClears = () => writes.join("").split(ansiEscapes.clearTerminal).length - 1; // Leading commit only: previous height was 0, so no clear yet. expect(countClears()).toBe(0); // Mutate inside the throttle window so a trailing commit is armed. msg.value = "B"; await nextTick(); expect(vi.getTimerCount()).toBeGreaterThanOrEqual(1); // Resize while the trailing commit is pending: paints synchronously (1 clear) // and must cancel the pending timer so it doesn't paint (and clear) again. stdout.emit("resize"); vi.advanceTimersByTime(1000); expect(countClears()).toBe(1); expect(stripAnsi(writes.join(""))).toContain("B"); app.unmount(); } finally { vi.useRealTimers(); } });