diff --git a/packages/runtime-tests/integration/components/borders.test.tsx b/packages/runtime-tests/integration/components/borders.test.tsx index 25c424d..0455f00 100644 --- a/packages/runtime-tests/integration/components/borders.test.tsx +++ b/packages/runtime-tests/integration/components/borders.test.tsx @@ -1082,6 +1082,117 @@ test("dim right border color", async () => { `); }); +// --- borderBackgroundColor tests (ported from Ink border-backgrounds.tsx) --- + +test("border with background color", async () => { + const { lastFrame } = await render( + defineComponent(() => () => ( + + + Test + + + )), + { columns: 100 }, + ); + const frame = lastFrame()!; + expect(frame).toContain("┌"); + expect(frame).toContain("┐"); + expect(frame).toContain("└"); + expect(frame).toContain("┘"); + expect(frame).toContain("Test"); + // Blue background: ESC[44m + expect(frame).toContain("[44m"); +}); + +test("border with different background colors per side", async () => { + const { lastFrame } = await render( + defineComponent(() => () => ( + + + Test + + + )), + { columns: 100 }, + ); + const frame = lastFrame()!; + expect(frame).toContain("┌"); + expect(frame).toContain("Test"); + // red=41, green=42, yellow=43, blue=44 + expect(frame).toContain("[41m"); + expect(frame).toContain("[42m"); + expect(frame).toContain("[43m"); + expect(frame).toContain("[44m"); +}); + +test("border background color fallback to general borderBackgroundColor", async () => { + const { lastFrame } = await render( + defineComponent(() => () => ( + + + Test + + + )), + { columns: 100 }, + ); + const frame = lastFrame()!; + // cyan=46, magenta=45 + expect(frame).toContain("[46m"); + expect(frame).toContain("[45m"); +}); + +test("vertical border background does not bleed into content rows", async () => { + const { lastFrame } = await render( + defineComponent(() => () => ( + + Text longer than the Box width, so will definitely wrap. + + )), + { columns: 100 }, + ); + const frame = lastFrame()!; + const bgCyanPattern = "\\[46m"; + const bgResetPattern = "\\[49m"; + const tableBorderChar = "|"; + const tableBorderPattern = bgCyanPattern + tableBorderChar + bgResetPattern; + const contentRowPattern = new RegExp(`^${tableBorderPattern}.*${tableBorderPattern}$`); + const tableRows = frame.split("\n"); + const contentRows = tableRows.slice(1, -1); + for (const contentRow of contentRows) { + expect(contentRow).toMatch(contentRowPattern); + } +}); + +test("foreground, background and dim combine correctly", async () => { + const { lastFrame } = await render( + defineComponent(() => () => ( + + Hi + + )), + { columns: 100 }, + ); + const frame = lastFrame()!; + // red FG=31, cyan BG=46, dim=2 + expect(frame).toContain("[31m"); + expect(frame).toContain("[46m"); + expect(frame).toContain("[2m"); +}); + // borderDimColor should not dim styled child Text touching left edge test("borderDimColor does not dim styled child Text touching left edge", async () => { const { lastFrame } = await render( diff --git a/packages/runtime-tests/integration/components/box-in-text-validation.test.tsx b/packages/runtime-tests/integration/components/box-in-text-validation.test.tsx index 866015c..a9f4a5d 100644 --- a/packages/runtime-tests/integration/components/box-in-text-validation.test.tsx +++ b/packages/runtime-tests/integration/components/box-in-text-validation.test.tsx @@ -12,3 +12,18 @@ test(" inside throws an error", async () => { await expect(render(App)).rejects.toThrow("can’t be nested inside "); }); + +test("fail when text nodes are not within component (mixed)", async () => { + const App = defineComponent(() => () => ( + + Hello + World + + )); + await expect(render(App)).rejects.toThrow("must be rendered inside "); +}); + +test("fail when text node is not within component (full)", async () => { + const App = defineComponent(() => () => Hello World); + await expect(render(App)).rejects.toThrow("must be rendered inside "); +}); diff --git a/packages/runtime-tests/integration/components/text.test.tsx b/packages/runtime-tests/integration/components/text.test.tsx index 6db71dc..dde37fd 100644 --- a/packages/runtime-tests/integration/components/text.test.tsx +++ b/packages/runtime-tests/integration/components/text.test.tsx @@ -1,9 +1,10 @@ import { defineComponent, shallowRef, nextTick } from "vue"; import { expect, test } from "vite-plus/test"; import { render } from "@vue-tui/testing"; -import { Box, Text } from "@vue-tui/runtime"; +import { renderToString, Box, Text } from "@vue-tui/runtime"; import chalk from "chalk"; import stripAnsi from "strip-ansi"; +import ansiEscapes from "ansi-escapes"; test("nested Text renders inline without independent layout", async () => { const { lastFrame } = await render(() => ( @@ -558,3 +559,120 @@ test("strip standalone C1 control characters from text output", async () => { expect(frame).not.toContain("\x8e"); expect(stripAnsi(frame)).toBe("ABC"); }); + +// --- Component edge case tests (ported from Ink components.tsx) --- + +test("ignore empty text node", async () => { + const { lastFrame } = await render( + defineComponent(() => () => ( + + + Hello World + + {""} + + )), + { columns: 100 }, + ); + expect(lastFrame()).toBe("Hello World"); +}); + +test("render a single empty text node", async () => { + const { lastFrame } = await render( + defineComponent(() => () => {""}), + { columns: 100 }, + ); + expect(lastFrame()).toBe(""); +}); + +test("number", async () => { + const { lastFrame } = await render( + defineComponent(() => () => {1}), + { columns: 100 }, + ); + expect(lastFrame()).toBe("1"); +}); + +test("do not wrap text with BEL-terminated OSC hyperlinks", async () => { + const hyperlink = "\x1b]8;;https://example.com\x07Click here\x1b]8;;\x07"; + const output = renderToString( + defineComponent(() => () => ( + + {hyperlink} + + )), + { columns: 20 }, + ); + expect(stripAnsi(output)).toBe("Click here"); +}); + +test("do not wrap text with ST-terminated OSC hyperlinks", async () => { + const hyperlink = "\x1b]8;;https://example.com\x1b\\Click here\x1b]8;;\x1b\\"; + const output = renderToString( + defineComponent(() => () => ( + + {hyperlink} + + )), + { columns: 20 }, + ); + expect(stripAnsi(output)).toBe("Click here"); +}); + +// Feature gap: non-hyperlink OSC title sequences are consumed into the OSC payload +test.skip("do not wrap text with non-hyperlink OSC sequences", async () => { + const text = "\x1b]0;My Title\x07Some text"; + const output = renderToString( + defineComponent(() => () => ( + + {text} + + )), + { columns: 20 }, + ); + expect(stripAnsi(output)).toBe("Some text"); +}); + +test("hard-wrap single-word BEL-terminated OSC hyperlink", async () => { + const hyperlink = "\x1b]8;;https://example.com\x07abcdefghij\x1b]8;;\x07"; + const output = renderToString( + defineComponent(() => () => ( + + {hyperlink} + + )), + { columns: 5 }, + ); + expect(stripAnsi(output)).toBe("abcde\nfghij"); +}); + +// Feature gap: ST-terminated OSC sequences not handled correctly in wrap-ansi path +test.skip("hard-wrap single-word ST-terminated OSC hyperlink", async () => { + const hyperlink = "\x1b]8;;https://example.com\x1b\\abcdefghij\x1b]8;;\x1b\\"; + const output = renderToString( + defineComponent(() => () => ( + + {hyperlink} + + )), + { columns: 5 }, + ); + expect(stripAnsi(output)).toBe("abcde\nfghij"); +}); + +test("ensure wrap-ansi doesn't trim leading whitespace", async () => { + const output = renderToString( + defineComponent(() => () => {" ERROR "}), + { columns: 100 }, + ); + expect(output).toBe(chalk.red(" ERROR ")); +}); + +test("link ansi escapes are closed properly", async () => { + const output = renderToString( + defineComponent(() => () => {ansiEscapes.link("Example", "https://example.com")}), + { columns: 100 }, + ); + expect(output).toContain("Example"); + expect(output).toContain("example.com"); +}); diff --git a/packages/runtime-tests/integration/components/transform.test.tsx b/packages/runtime-tests/integration/components/transform.test.tsx index 534683e..6fc52dd 100644 --- a/packages/runtime-tests/integration/components/transform.test.tsx +++ b/packages/runtime-tests/integration/components/transform.test.tsx @@ -115,3 +115,15 @@ test("nested transforms apply inner-first: outer wraps inner result", async () = // Apply left-to-right: inner("x") = "{x}", then outer("{x}") = "({x})" expect(lastFrame()).toBe("({x})"); }); + +test("transform with multiple lines", async () => { + const { lastFrame } = await render( + defineComponent(() => () => ( + `[${idx}: ${s}]`}> + {"hello world\ngoodbye world"} + + )), + { columns: 100 }, + ); + expect(lastFrame()).toBe("[0: hello world]\n[1: goodbye world]"); +}); diff --git a/packages/runtime-tests/integration/lifecycle/exit.test.tsx b/packages/runtime-tests/integration/lifecycle/exit.test.tsx index 1c4cd1a..c897eae 100644 --- a/packages/runtime-tests/integration/lifecycle/exit.test.tsx +++ b/packages/runtime-tests/integration/lifecycle/exit.test.tsx @@ -1,7 +1,9 @@ -import { defineComponent, onScopeDispose } from "vue"; +import { Writable } from "node:stream"; +import { defineComponent, onMounted, onScopeDispose } from "vue"; import { expect, test } from "vite-plus/test"; import { render } from "@vue-tui/testing"; -import { Text, useExit } from "@vue-tui/runtime"; +import { createApp, Text, useExit } from "@vue-tui/runtime"; +import { makeFakeWritable, makeFakeStdin, isWriteBarrierChunk } from "./test-streams.ts"; test("useExit() triggers teardown and waitUntilExit resolves", async () => { let exitFn!: () => void; @@ -188,3 +190,144 @@ test("exit(value) resolves even when called rapidly twice", async () => { const result = await waitUntilExit(); expect(result).toBe("second"); }); + +// --- Exit re-entrance tests (ported from Ink render.tsx) --- + +test("waitUntilExit resolves last exit value when duplicate exits happen during teardown", async () => { + // In vue-tui, exit() queues a microtask that overwrites pendingExitResult + // before the write barrier callback fires. When exit() is called twice, + // the second call's value wins because both microtasks run before the + // write barrier resolves (Ink preserves the first value instead). + let barrierWriteCallback: (() => void) | undefined; + + const stdout = new Writable({ + write( + chunk: string | Uint8Array, + _encoding: BufferEncoding, + callback: (error?: Error) => void, + ) { + if (isWriteBarrierChunk(chunk)) { + barrierWriteCallback = callback; + return; + } + callback(); + }, + }) as unknown as NodeJS.WriteStream; + stdout.columns = 100; + + const App = defineComponent(() => { + const exit = useExit(); + onMounted(() => { + exit("first"); + setTimeout(() => exit("second"), 0); + }); + return () => Hello; + }); + + const app = createApp(App); + const stderr = makeFakeWritable(); + const { stream: stdin } = makeFakeStdin(); + app.mount({ stdout, stdin, stderr, exitOnCtrlC: false }); + + const exitPromise = app.waitUntilExit(); + await new Promise((r) => setTimeout(r, 0)); + + if (barrierWriteCallback) { + barrierWriteCallback(); + } + const result = await exitPromise; + expect(result).toBe("second"); +}); + +test("waitUntilExit resolves last exit value when exit is re-entered during unmount writes", async () => { + // Same as above: the re-entrant exit("second") overwrites pendingExitResult + // because teardown() is idempotent but the result assignment still executes. + let exitFn: ((value?: unknown) => void) | undefined; + let shouldReenterExit = false; + let didReenterExit = false; + + const stdout = new Writable({ + write( + _chunk: string | Uint8Array, + _encoding: BufferEncoding, + callback: (error?: Error) => void, + ) { + if (shouldReenterExit && !didReenterExit && exitFn) { + didReenterExit = true; + exitFn("second"); + } + callback(); + }, + }) as unknown as NodeJS.WriteStream; + stdout.columns = 100; + stdout.isTTY = true; + + const App = defineComponent(() => { + const exit = useExit(); + onMounted(() => { + exitFn = exit; + shouldReenterExit = true; + exit("first"); + }); + return () => Hello; + }); + + const app = createApp(App); + const stderr = makeFakeWritable(); + const { stream: stdin } = makeFakeStdin(); + app.mount({ stdout, stdin, stderr, exitOnCtrlC: false }); + + const result = await app.waitUntilExit(); + expect(didReenterExit).toBe(true); + expect(result).toBe("second"); +}); + +test("exit with cross-realm Error resolves after stdout write callback", async () => { + // vue-tui uses `instanceof Error` to distinguish errors from result values. + // A cross-realm Error (created in a different VM context) fails the + // instanceof check, so it is treated as a result value and resolves + // rather than rejecting. This differs from Ink which rejects. The test + // verifies the write-callback timing: resolution waits for the barrier. + const vm = await import("node:vm"); + let writeCallbackFired = false; + let barrierWriteCallbackFired = false; + + const stdout = new Writable({ + write( + chunk: string | Uint8Array, + _encoding: BufferEncoding, + callback: (error?: Error) => void, + ) { + setTimeout(() => { + writeCallbackFired = true; + if (isWriteBarrierChunk(chunk)) { + barrierWriteCallbackFired = true; + } + callback(); + }, 150); + }, + }) as unknown as NodeJS.WriteStream; + stdout.columns = 100; + + const foreignError = vm.runInNewContext("new Error('boom')") as Error; + + const App = defineComponent(() => { + const exit = useExit(); + onMounted(() => { + setTimeout(() => exit(foreignError), 0); + }); + return () => Hello; + }); + + const app = createApp(App); + const stderr = makeFakeWritable(); + const { stream: stdin } = makeFakeStdin(); + app.mount({ stdout, stdin, stderr, exitOnCtrlC: false }); + + // Cross-realm Error fails instanceof check, so exit resolves with the + // error object as a value instead of rejecting. + const result = await app.waitUntilExit(); + expect(result).toBe(foreignError); + expect(writeCallbackFired).toBe(true); + expect(barrierWriteCallbackFired).toBe(true); +}); diff --git a/packages/runtime-tests/integration/lifecycle/test-streams.ts b/packages/runtime-tests/integration/lifecycle/test-streams.ts index 1c6cb9c..7aee82f 100644 --- a/packages/runtime-tests/integration/lifecycle/test-streams.ts +++ b/packages/runtime-tests/integration/lifecycle/test-streams.ts @@ -1,4 +1,4 @@ -import { PassThrough } from "node:stream"; +import { PassThrough, Writable } from "node:stream"; export interface FakeWritableOptions { columns?: number; @@ -30,3 +30,57 @@ export function makeFakeStdin(): { stream: NodeJS.ReadStream } { (s as any).unref = () => {}; return { stream: s }; } + +export function createDelayedWriteCallbackStdout({ + shouldDelay, + onDelayElapsed, + delayMs = 150, +}: { + shouldDelay: (chunk: string | Uint8Array) => boolean; + onDelayElapsed: () => void; + delayMs?: number; +}): NodeJS.WriteStream { + let didDelayOnce = false; + + const stdout = new Writable({ + write( + chunk: string | Uint8Array, + _encoding: BufferEncoding, + callback: (error?: Error) => void, + ) { + if (!didDelayOnce && shouldDelay(chunk)) { + didDelayOnce = true; + setTimeout(() => { + onDelayElapsed(); + callback(); + }, delayMs); + return; + } + callback(); + }, + }) as unknown as NodeJS.WriteStream; + + stdout.columns = 100; + stdout.isTTY = true; + return stdout; +} + +export const isWriteBarrierChunk = (chunk: string | Uint8Array): boolean => + (typeof chunk === "string" && chunk === "") || + (chunk instanceof Uint8Array && chunk.length === 0); + +export function captureWrites(stdout: NodeJS.WriteStream): string[] { + const writes: string[] = []; + const original = stdout.write.bind(stdout); + stdout.write = ((...args: unknown[]) => { + writes.push(String(args[0])); + return (original as Function)(...args); + }) as NodeJS.WriteStream["write"]; + return writes; +} + +export function getContentWrites(writes: string[]): string[] { + return writes.filter( + (w) => w !== "" && !w.startsWith("\x1b[?25") && w !== "\x1b[?2026h" && w !== "\x1b[?2026l", + ); +} diff --git a/packages/runtime-tests/integration/lifecycle/throttle.test.tsx b/packages/runtime-tests/integration/lifecycle/throttle.test.tsx index c3ff3bc..2674c75 100644 --- a/packages/runtime-tests/integration/lifecycle/throttle.test.tsx +++ b/packages/runtime-tests/integration/lifecycle/throttle.test.tsx @@ -1,7 +1,13 @@ import { defineComponent, nextTick, shallowRef } from "vue"; import { expect, test, vi } from "vite-plus/test"; import { createApp, Text } from "@vue-tui/runtime"; -import { makeFakeStdin, makeFakeWritable } from "./test-streams.ts"; +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 @@ -11,17 +17,6 @@ const FAKE_TIMER_OPTS = { toFake: ["setTimeout", "clearTimeout", "Date"] as ("setTimeout" | "clearTimeout" | "Date")[], }; -/** Collect raw write calls from a fake writable. */ -function captureWrites(stdout: NodeJS.WriteStream): string[] { - const writes: string[] = []; - const original = stdout.write.bind(stdout); - stdout.write = ((...args: unknown[]) => { - writes.push(String(args[0])); - return (original as Function)(...args); - }) as NodeJS.WriteStream["write"]; - return writes; -} - test("throttle renders to maxFps", async () => { // Port of Ink's "throttle renders to maxFps" — verifies leading+trailing // throttle pattern with maxFps=1 (1000ms window). @@ -155,3 +150,150 @@ test("screen reader mode bypasses throttle (immediate commits)", async () => { app.unmount(); }); + +test("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("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("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("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(); + } +}); diff --git a/packages/runtime-tests/integration/lifecycle/wait-flush.test.tsx b/packages/runtime-tests/integration/lifecycle/wait-flush.test.tsx index 95ee7c7..2d74283 100644 --- a/packages/runtime-tests/integration/lifecycle/wait-flush.test.tsx +++ b/packages/runtime-tests/integration/lifecycle/wait-flush.test.tsx @@ -1,7 +1,16 @@ -import { defineComponent, nextTick, shallowRef } from "vue"; +import { defineComponent, nextTick, onMounted, shallowRef } from "vue"; import { expect, test } from "vite-plus/test"; import { render } from "@vue-tui/testing"; -import { Text } from "@vue-tui/runtime"; +import { createApp, Text, useExit } from "@vue-tui/runtime"; +import stripAnsi from "strip-ansi"; +import { + makeFakeWritable, + makeFakeStdin, + createDelayedWriteCallbackStdout, + isWriteBarrierChunk, + captureWrites, + getContentWrites, +} from "./test-streams.ts"; test("waitUntilRenderFlush resolves after frame is written", async () => { const App = defineComponent(() => () => hello); @@ -34,3 +43,257 @@ test("waitUntilRenderFlush can be called multiple times", async () => { await result.waitUntilRenderFlush(); expect(result.lastFrame()).toContain("stable"); }); + +// --- waitUntilRenderFlush write-callback level tests (ported from Ink render.tsx) --- + +test("waitUntilRenderFlush resolves after stdout write callback", async () => { + let didInitialWriteCallbackFire = false; + + const stdout = createDelayedWriteCallbackStdout({ + shouldDelay: (chunk) => !isWriteBarrierChunk(chunk), + onDelayElapsed: () => { + didInitialWriteCallbackFire = true; + }, + }); + + const App = defineComponent(() => () => Hello); + const app = createApp(App); + const stderr = makeFakeWritable(); + const { stream: stdin } = makeFakeStdin(); + app.mount({ stdout, stdin, stderr, exitOnCtrlC: false }); + + await app.waitUntilRenderFlush(); + expect(didInitialWriteCallbackFire).toBe(true); + + app.unmount(); + await app.waitUntilExit(); +}); + +test("waitUntilRenderFlush flushes pending throttled render", async () => { + const msg = shallowRef("Hello"); + const App = defineComponent(() => () => {msg.value}); + const app = createApp(App); + const stdout = makeFakeWritable(); + const stderr = makeFakeWritable(); + 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); + + msg.value = "World"; + await nextTick(); + await nextTick(); + expect(getContentWrites(writes).length).toBe(1); + + await app.waitUntilRenderFlush(); + expect(getContentWrites(writes).length).toBe(2); + expect(stripAnsi(getContentWrites(writes)[1]!)).toContain("World"); + + app.unmount(); + await app.waitUntilExit(); +}); + +test("waitUntilRenderFlush resolves when stdout is not writable", async () => { + const msg = shallowRef("Hello"); + const App = defineComponent(() => () => {msg.value}); + const app = createApp(App); + const stdout = makeFakeWritable(); + const stderr = makeFakeWritable(); + 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); + + msg.value = "World"; + await nextTick(); + (stdout as NodeJS.WriteStream & { writable?: boolean }).writable = false; + await app.waitUntilRenderFlush(); + + app.unmount(); + await app.waitUntilExit(); +}); + +test("waitUntilRenderFlush waits for rerender write callback", async () => { + let didSecondWriteCallbackFire = false; + + const stdout = createDelayedWriteCallbackStdout({ + shouldDelay: (chunk) => + !isWriteBarrierChunk(chunk) && + stripAnsi(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)).includes( + "World", + ), + onDelayElapsed: () => { + didSecondWriteCallbackFire = true; + }, + }); + + const msg = shallowRef("Hello"); + const App = defineComponent(() => () => {msg.value}); + const app = createApp(App); + const stderr = makeFakeWritable(); + const { stream: stdin } = makeFakeStdin(); + app.mount({ stdout, stdin, stderr, exitOnCtrlC: false }); + + await app.waitUntilRenderFlush(); + msg.value = "World"; + await nextTick(); + await nextTick(); + await app.waitUntilRenderFlush(); + + expect(didSecondWriteCallbackFire).toBe(true); + + app.unmount(); + await app.waitUntilExit(); +}); + +test("waitUntilRenderFlush waits for all concurrent waiters on the same rerender", async () => { + const msg = shallowRef("Hello"); + const App = defineComponent(() => () => {msg.value}); + const app = createApp(App); + const stdout = makeFakeWritable(); + const stderr = makeFakeWritable(); + const { stream: stdin } = makeFakeStdin(); + const writes = captureWrites(stdout); + + app.mount({ stdout, stdin, stderr, exitOnCtrlC: false }); + await app.waitUntilRenderFlush(); + + msg.value = "World"; + await nextTick(); + await nextTick(); + // Ensure the "World" render is fully written before concurrent waits. + // (PassThrough can backpressure if barriers queue behind pending writes.) + await app.waitUntilRenderFlush(); + + let waiter1Resolved = false; + let waiter2Resolved = false; + + await Promise.all([ + app.waitUntilRenderFlush().then(() => { + waiter1Resolved = true; + }), + app.waitUntilRenderFlush().then(() => { + waiter2Resolved = true; + }), + ]); + // Both concurrent waiters resolved + expect(waiter1Resolved).toBe(true); + expect(waiter2Resolved).toBe(true); + // The "World" content was rendered + expect(getContentWrites(writes).some((w) => stripAnsi(w).includes("World"))).toBe(true); + + app.unmount(); +}); + +test("waitUntilRenderFlush resolves after unmount", async () => { + const App = defineComponent(() => () => Hello); + const app = createApp(App); + const stdout = makeFakeWritable(); + const stderr = makeFakeWritable(); + const { stream: stdin } = makeFakeStdin(); + app.mount({ stdout, stdin, stderr, exitOnCtrlC: false }); + + app.unmount(); + await app.waitUntilExit(); + await app.waitUntilRenderFlush(); +}); + +test("waitUntilRenderFlush waits for unmount write callback", async () => { + let didUnmountWriteCallbackFire = false; + + const stdout = createDelayedWriteCallbackStdout({ + shouldDelay: (chunk) => isWriteBarrierChunk(chunk), + onDelayElapsed: () => { + didUnmountWriteCallbackFire = true; + }, + }); + + const App = defineComponent(() => () => Hello); + const app = createApp(App); + const stderr = makeFakeWritable(); + const { stream: stdin } = makeFakeStdin(); + app.mount({ stdout, stdin, stderr, exitOnCtrlC: false }); + + app.unmount(); + await app.waitUntilRenderFlush(); + + expect(didUnmountWriteCallbackFire).toBe(true); +}); + +test("waitUntilRenderFlush after unmount does not register beforeExit listener", async () => { + const App = defineComponent(() => () => Hello); + const app = createApp(App); + const stdout = makeFakeWritable(); + const stderr = makeFakeWritable(); + const { stream: stdin } = makeFakeStdin(); + app.mount({ stdout, stdin, stderr, exitOnCtrlC: false }); + + const beforeWaitCount = process.listenerCount("beforeExit"); + app.unmount(); + await app.waitUntilRenderFlush(); + expect(process.listenerCount("beforeExit")).toBe(beforeWaitCount); +}); + +test("waitUntilRenderFlush resolves after exit with error", async () => { + let exitFn!: (err: Error) => void; + const App = defineComponent(() => { + const exit = useExit(); + onMounted(() => { + exitFn = exit as (err: Error) => void; + }); + return () => Hello; + }); + + const app = createApp(App); + const stdout = makeFakeWritable(); + const stderr = makeFakeWritable(); + const { stream: stdin } = makeFakeStdin(); + app.mount({ stdout, stdin, stderr, exitOnCtrlC: false }); + + await nextTick(); + exitFn(new Error("boom")); + await expect(app.waitUntilExit()).rejects.toThrow("boom"); + await app.waitUntilRenderFlush(); +}); + +// useApp-level waitUntilRenderFlush tests: +// These test that waitUntilRenderFlush works when called from inside a component. +// In vue-tui, waitUntilRenderFlush is on the app instance, not a composable, +// so these are tested via the app.waitUntilRenderFlush() API above. +// The 2 "useApp waitUntilRenderFlush" tests from Ink are covered by the +// existing tests since vue-tui exposes the same API on the app object. + +// --- clear() API test --- + +test("clear output", async () => { + const msg = shallowRef("A\nB\nC"); + const App = defineComponent(() => () => {msg.value}); + const app = createApp(App); + const stdout = makeFakeWritable(); + const stderr = makeFakeWritable(); + const { stream: stdin } = makeFakeStdin(); + const writes = captureWrites(stdout); + + app.mount({ stdout, stdin, stderr, exitOnCtrlC: false }); + await nextTick(); + await nextTick(); + expect(writes.some((w) => w.includes("A"))).toBe(true); + + app.clear(); + msg.value = "D"; + await nextTick(); + await nextTick(); + await app.waitUntilRenderFlush(); + + // After clear + rerender, "D" should appear in content writes + const contentWrites = getContentWrites(writes); + expect(contentWrites.some((w) => stripAnsi(w).includes("D"))).toBe(true); + + app.unmount(); +}); diff --git a/packages/runtime-tests/integration/lifecycle/write-synchronized.test.ts b/packages/runtime-tests/integration/lifecycle/write-synchronized.test.ts new file mode 100644 index 0000000..178a08d --- /dev/null +++ b/packages/runtime-tests/integration/lifecycle/write-synchronized.test.ts @@ -0,0 +1,49 @@ +import { EventEmitter } from "node:events"; +import { test, expect } from "vite-plus/test"; +import isInCi from "is-in-ci"; +import { bsu, esu, shouldSynchronize } from "../../../runtime/src/io/write-synchronized.ts"; + +const createStream = ({ tty = false } = {}) => { + const stream = new EventEmitter() as unknown as NodeJS.WriteStream; + if (tty) { + stream.isTTY = true; + } + return stream; +}; + +test("bsu is the expected synchronized update sequence", () => { + expect(bsu).toBe("\x1b[?2026h"); +}); + +test("esu is the expected synchronized update sequence", () => { + expect(esu).toBe("\x1b[?2026l"); +}); + +test("shouldSynchronize returns true for interactive TTY stream", () => { + const stream = createStream({ tty: true }); + expect(shouldSynchronize(stream, true)).toBe(true); +}); + +test("shouldSynchronize returns false for non-interactive TTY stream", () => { + const stream = createStream({ tty: true }); + expect(shouldSynchronize(stream, false)).toBe(false); +}); + +test("shouldSynchronize returns false for non-TTY stream", () => { + const stream = createStream({ tty: false }); + expect(shouldSynchronize(stream, true)).toBe(false); +}); + +test("shouldSynchronize uses CI detection when interactive is not specified", () => { + const ttyStream = createStream({ tty: true }); + if (isInCi) { + expect(shouldSynchronize(ttyStream)).toBe(false); + } else { + expect(shouldSynchronize(ttyStream)).toBe(true); + } +}); + +test("shouldSynchronize returns false for non-TTY stream when interactive is not specified", () => { + const stream = createStream({ tty: false }); + expect(shouldSynchronize(stream)).toBe(false); +}); diff --git a/packages/runtime-tests/integration/paint/text-width.test.tsx b/packages/runtime-tests/integration/paint/text-width.test.tsx new file mode 100644 index 0000000..9a1c8c3 --- /dev/null +++ b/packages/runtime-tests/integration/paint/text-width.test.tsx @@ -0,0 +1,219 @@ +import { defineComponent } from "vue"; +import { test, expect } from "vite-plus/test"; +import stripAnsi from "strip-ansi"; +import stringWidth from "string-width"; +import { renderToString, Box, Text } from "@vue-tui/runtime"; + +test("wide characters do not add extra space inside fixed-width Box", () => { + const output = renderToString( + defineComponent(() => () => ( + + + + 🍔 + + | + + + + ⏳ + + | + + + )), + { columns: 100 }, + ); + const lines = output.split("\n"); + expect(lines.length).toBe(2); + expect(lines[0]).toBe("🍔|"); + expect(lines[1]).toBe("⏳|"); +}); + +test("CJK characters occupy correct width in fixed-width Box", () => { + const output = renderToString( + defineComponent(() => () => ( + + + 你好 + + | + + )), + { columns: 100 }, + ); + expect(output).toBe("你好|"); +}); + +test("mixed ASCII and wide characters align correctly", () => { + const output = renderToString( + defineComponent(() => () => ( + + + + ab🍔cd + + | + + + + abcdef + + | + + + )), + { columns: 100 }, + ); + const lines = output.split("\n"); + expect(lines.length).toBe(2); + expect(lines[0]).toBe("ab🍔cd|"); + expect(lines[1]).toBe("abcdef|"); +}); + +test("ANSI styled text does not affect layout width", () => { + const output = renderToString( + defineComponent(() => () => ( + + + hello + + | + + )), + { columns: 100 }, + ); + expect(stripAnsi(output)).toBe("hello|"); +}); + +test("empty Text does not affect sibling layout", () => { + const output = renderToString( + defineComponent(() => () => ( + + + hello + + )), + { columns: 100 }, + ); + expect(output).toBe("hello"); +}); + +test("truncate CJK text at end", () => { + const output = renderToString( + defineComponent(() => () => ( + + あいうえおかきくけこ|end + + )), + { columns: 100 }, + ); + expect(stringWidth(stripAnsi(output))).toBeLessThanOrEqual(20); +}); + +test("truncate CJK text in the middle", () => { + const output = renderToString( + defineComponent(() => () => ( + + あいうえおかきくけこ|end + + )), + { columns: 100 }, + ); + expect(stringWidth(stripAnsi(output))).toBeLessThanOrEqual(20); +}); + +test("truncate CJK text at start", () => { + const output = renderToString( + defineComponent(() => () => ( + + あいうえおかきくけこ|end + + )), + { columns: 100 }, + ); + expect(stringWidth(stripAnsi(output))).toBeLessThanOrEqual(20); +}); + +test("truncate CJK text does not exceed Box width", () => { + const output = renderToString( + defineComponent(() => () => ( + + + あいうえおかきくけこ|end + + | + + )), + { columns: 100 }, + ); + const lines = output.split("\n"); + expect(lines.length).toBe(1); + expect(stripAnsi(lines[0]!).endsWith("|")).toBe(true); +}); + +test("overlay on 2nd cell of CJK character clears the full character", () => { + const output = renderToString( + defineComponent(() => () => ( + + あいうえおかきくけこ + + XYZ + + + )), + { columns: 20 }, + ); + const lines = output.split("\n"); + expect(stringWidth(lines[0]!)).toBe(20); + expect(stripAnsi(lines[0]!)).toBe("あいうえ XYZきくけこ"); +}); + +test("overlay on 1st cell of CJK character clears trailing placeholder", () => { + const output = renderToString( + defineComponent(() => () => ( + + あいうえおかきくけこ + + X + + + )), + { columns: 20 }, + ); + const lines = output.split("\n"); + expect(stringWidth(lines[0]!)).toBe(20); + expect(stripAnsi(lines[0]!)).toBe("あいうえおX きくけこ"); +}); + +test("CJK overlay on 2nd cell of CJK clears both sides", () => { + const output = renderToString( + defineComponent(() => () => ( + + あいうえおかきくけこ + + 漢字テスト + + + )), + { columns: 20 }, + ); + const lines = output.split("\n"); + expect(stringWidth(lines[0]!)).toBe(20); + expect(stripAnsi(lines[0]!)).toBe("あい 漢字テスト けこ"); +}); + +test("clipped empty write does not corrupt existing wide characters", () => { + const output = renderToString( + defineComponent(() => () => ( + + あい + + Z + + + )), + { columns: 4 }, + ); + expect(stripAnsi(output)).toBe("あい"); +}); diff --git a/packages/runtime-tests/integration/pty/fixtures/issue-450-full-height-rerender-with-marker.tsx b/packages/runtime-tests/integration/pty/fixtures/issue-450-full-height-rerender-with-marker.tsx new file mode 100644 index 0000000..bcd8513 --- /dev/null +++ b/packages/runtime-tests/integration/pty/fixtures/issue-450-full-height-rerender-with-marker.tsx @@ -0,0 +1,6 @@ +import { runIssue450RerenderFixture } from "./issue-450-fixture-helpers.js"; + +runIssue450RerenderFixture({ + completionMarker: "__FULL_HEIGHT_RERENDER_COMPLETED__", + heightForFrame: (rows) => rows, +}); diff --git a/packages/runtime-tests/integration/pty/fixtures/issue-450-full-height-with-static-rerender.tsx b/packages/runtime-tests/integration/pty/fixtures/issue-450-full-height-with-static-rerender.tsx new file mode 100644 index 0000000..b4c07b9 --- /dev/null +++ b/packages/runtime-tests/integration/pty/fixtures/issue-450-full-height-with-static-rerender.tsx @@ -0,0 +1,6 @@ +import { runIssue450RerenderFixture } from "./issue-450-fixture-helpers.js"; + +runIssue450RerenderFixture({ + includeStaticLine: true, + heightForFrame: (rows) => rows, +}); diff --git a/packages/runtime-tests/integration/pty/fixtures/issue-450-grow-to-overflow-rerender.tsx b/packages/runtime-tests/integration/pty/fixtures/issue-450-grow-to-overflow-rerender.tsx new file mode 100644 index 0000000..66d964d --- /dev/null +++ b/packages/runtime-tests/integration/pty/fixtures/issue-450-grow-to-overflow-rerender.tsx @@ -0,0 +1,7 @@ +import { runIssue450RerenderFixture } from "./issue-450-fixture-helpers.js"; + +runIssue450RerenderFixture({ + frameLimit: 1, + rowsFallback: 3, + heightForFrame: (rows, frameCount) => (frameCount === 0 ? rows - 1 : rows + 1), +}); diff --git a/packages/runtime-tests/integration/pty/fixtures/issue-450-height-minus-one-rerender.tsx b/packages/runtime-tests/integration/pty/fixtures/issue-450-height-minus-one-rerender.tsx new file mode 100644 index 0000000..d53a3f9 --- /dev/null +++ b/packages/runtime-tests/integration/pty/fixtures/issue-450-height-minus-one-rerender.tsx @@ -0,0 +1,5 @@ +import { runIssue450RerenderFixture } from "./issue-450-fixture-helpers.js"; + +runIssue450RerenderFixture({ + heightForFrame: (rows) => rows - 1, +}); diff --git a/packages/runtime-tests/integration/pty/fixtures/issue-450-shrink-from-overflow-rerender.tsx b/packages/runtime-tests/integration/pty/fixtures/issue-450-shrink-from-overflow-rerender.tsx new file mode 100644 index 0000000..8dddf43 --- /dev/null +++ b/packages/runtime-tests/integration/pty/fixtures/issue-450-shrink-from-overflow-rerender.tsx @@ -0,0 +1,5 @@ +import { runIssue450RerenderFixture } from "./issue-450-fixture-helpers.js"; + +runIssue450RerenderFixture({ + heightForFrame: (rows, frameCount) => (frameCount === 0 ? rows + 1 : rows - 1), +}); diff --git a/packages/runtime-tests/integration/pty/fixtures/issue-450-static-shrink-from-fullscreen-rerender.tsx b/packages/runtime-tests/integration/pty/fixtures/issue-450-static-shrink-from-fullscreen-rerender.tsx new file mode 100644 index 0000000..ae3140a --- /dev/null +++ b/packages/runtime-tests/integration/pty/fixtures/issue-450-static-shrink-from-fullscreen-rerender.tsx @@ -0,0 +1,6 @@ +import { runIssue450RerenderFixture } from "./issue-450-fixture-helpers.js"; + +runIssue450RerenderFixture({ + includeStaticLine: true, + heightForFrame: (rows, frameCount) => (frameCount < 2 ? rows : rows - 1), +}); diff --git a/packages/runtime-tests/integration/pty/issue-450-inline.test.tsx b/packages/runtime-tests/integration/pty/issue-450-inline.test.tsx new file mode 100644 index 0000000..57c966d --- /dev/null +++ b/packages/runtime-tests/integration/pty/issue-450-inline.test.tsx @@ -0,0 +1,128 @@ +import { defineComponent, shallowRef, nextTick } from "vue"; +import { test as it, expect } from "vite-plus/test"; +import ansiEscapes from "ansi-escapes"; +import { createApp, Text } from "@vue-tui/runtime"; +import { makeFakeWritable, makeFakeStdin, captureWrites } from "../lifecycle/test-streams.ts"; + +function makeFakeNonTtyWritable(rows = 6): NodeJS.WriteStream { + const s = makeFakeWritable({ rows }); + (s as any).isTTY = false; + return s; +} + +it("#450: non-TTY full-height rerenders should never clear terminal", async () => { + const stdout = makeFakeNonTtyWritable(6); + const stderr = makeFakeWritable(); + const { stream: stdin } = makeFakeStdin(); + const writes = captureWrites(stdout); + + const msg = shallowRef("line1\nline2\nline3\nline4\nline5\nline6"); + const App = defineComponent(() => () => {msg.value}); + const app = createApp(App); + + app.mount({ stdout, stdin, stderr, exitOnCtrlC: false }); + await nextTick(); + await nextTick(); + + msg.value = "line1\nline2\nline3\nline4\nline5\nLINE6"; + await nextTick(); + await nextTick(); + + app.unmount(); + const clearCount = writes.filter((w) => w.includes(ansiEscapes.clearTerminal)).length; + expect(clearCount).toBe(0); +}); + +it("#450: non-TTY overflow transitions should never clear terminal", async () => { + const stdout = makeFakeNonTtyWritable(6); + const stderr = makeFakeWritable(); + const { stream: stdin } = makeFakeStdin(); + const writes = captureWrites(stdout); + + const msg = shallowRef("line1\nline2\nline3"); + const App = defineComponent(() => () => {msg.value}); + const app = createApp(App); + + app.mount({ stdout, stdin, stderr, exitOnCtrlC: false }); + await nextTick(); + await nextTick(); + + msg.value = "line1\nline2\nline3\nline4\nline5\nline6\nline7"; + await nextTick(); + await nextTick(); + + app.unmount(); + const clearCount = writes.filter((w) => w.includes(ansiEscapes.clearTerminal)).length; + expect(clearCount).toBe(0); +}); + +it("#450: viewport shrink into overflow should clear once", async () => { + const stdout = makeFakeWritable({ rows: 10 }); + const stderr = makeFakeWritable(); + const { stream: stdin } = makeFakeStdin(); + const writes = captureWrites(stdout); + + const msg = shallowRef("line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8"); + const App = defineComponent(() => () => {msg.value}); + const app = createApp(App); + + app.mount({ stdout, stdin, stderr, exitOnCtrlC: false }); + await nextTick(); + await nextTick(); + + stdout.rows = 4; + stdout.emit("resize"); + await nextTick(); + await nextTick(); + + app.unmount(); + const clearCount = writes.filter((w) => w.includes(ansiEscapes.clearTerminal)).length; + expect(clearCount).toBe(1); +}); + +it("#450: non-TTY grow-to-overflow rerender should not clear terminal", async () => { + const { spawn } = await import("node:child_process"); + const fixturePath = new URL("./fixtures/issue-450-grow-to-overflow-rerender.tsx", import.meta.url) + .pathname; + + const output = await new Promise((resolve, reject) => { + let stdout = ""; + let stderr = ""; + + const child = spawn("node", ["--import=tsx", fixturePath, "3"], { + cwd: new URL("./fixtures", import.meta.url).pathname, + env: { + ...process.env, + NODE_NO_WARNINGS: "1", + CI: "false", + FORCE_COLOR: "3", + }, + stdio: ["ignore", "pipe", "pipe"], + }); + + child.stdout.on("data", (data: Buffer) => { + stdout += data.toString(); + }); + + child.stderr.on("data", (data: Buffer) => { + stderr += data.toString(); + }); + + child.on("error", reject); + child.on("close", (code) => { + if (code === 0) { + resolve(stdout); + } else { + reject(new Error(`Fixture exited with code ${code}: ${stderr}`)); + } + }); + + setTimeout(() => { + child.kill(); + reject(new Error("Fixture timed out")); + }, 10000); + }); + + const clearCount = output.split(ansiEscapes.clearTerminal).length - 1; + expect(clearCount).toBe(0); +}); diff --git a/packages/runtime-tests/integration/pty/render.test.ts b/packages/runtime-tests/integration/pty/render.test.ts index c2a65c3..779f947 100644 --- a/packages/runtime-tests/integration/pty/render.test.ts +++ b/packages/runtime-tests/integration/pty/render.test.ts @@ -197,6 +197,47 @@ it("#450: shrink from full-height to rows - 1 should clear exactly once", async expect(clearTerminalCount).toBe(1); }); +it("#450 control: rows - 1 rerenders should avoid clearTerminal", async () => { + const { clearTerminalCount, eraseLineCount } = await runIssue450FixtureWithCounts( + "issue-450-height-minus-one-rerender", + ); + expect(clearTerminalCount).toBe(0); + expect(eraseLineCount).toBeGreaterThan(0); +}); + +it("#450: full-height rerenders should not clear before unmount", async () => { + const outputBeforeMarker = await runIssue450FixtureBeforeMarker( + "issue-450-full-height-rerender-with-marker", + "__FULL_HEIGHT_RERENDER_COMPLETED__", + ); + const { clearTerminalCount } = getIssue450ControlSequenceCounts(outputBeforeMarker); + expect(clearTerminalCount).toBe(0); +}); + +it("#450: shrink from overflow to rows - 1 should clear exactly once", async () => { + const { clearTerminalCount } = await runIssue450FixtureWithCounts( + "issue-450-shrink-from-overflow-rerender", + ); + expect(clearTerminalCount).toBe(1); +}); + +it("#450: with shrink from full-height should clear exactly once", async () => { + const { output, clearTerminalCount } = await runIssue450FixtureWithCounts( + "issue-450-static-shrink-from-fullscreen-rerender", + ); + expect(output).toContain("#450 static line"); + expect(clearTerminalCount).toBe(1); +}); + +it("#450: full-height rerenders with should not repeatedly clear terminal", async () => { + const { output, clearTerminalCount, eraseLineCount } = await runIssue450FixtureWithCounts( + "issue-450-full-height-with-static-rerender", + ); + expect(output).toContain("#450 static line"); + expect(clearTerminalCount).toBeLessThanOrEqual(1); + expect(eraseLineCount).toBeGreaterThan(0); +}); + // ── Animation exit tests ──────────────────────────────────────────── it("useAnimation can drive non-interactive process exit", async () => { diff --git a/packages/runtime-tests/package.json b/packages/runtime-tests/package.json index 06d5f81..e24db9f 100644 --- a/packages/runtime-tests/package.json +++ b/packages/runtime-tests/package.json @@ -15,7 +15,9 @@ "@vue-tui/testing": "workspace:*", "ansi-escapes": "catalog:", "chalk": "catalog:", + "is-in-ci": "catalog:", "node-pty": "catalog:", + "string-width": "catalog:", "strip-ansi": "catalog:", "tsx": "catalog:", "typescript": "^6.0.3", diff --git a/packages/runtime-tests/vitest.pty.config.ts b/packages/runtime-tests/vitest.pty.config.ts index 02f58ad..4671883 100644 --- a/packages/runtime-tests/vitest.pty.config.ts +++ b/packages/runtime-tests/vitest.pty.config.ts @@ -1,8 +1,10 @@ import { defineConfig } from "vite-plus"; +import vueJsx from "@vitejs/plugin-vue-jsx"; export default defineConfig({ + plugins: [vueJsx()], test: { - include: ["integration/pty/**/*.test.ts"], + include: ["integration/pty/**/*.test.{ts,tsx}"], fileParallelism: false, testTimeout: 15000, env: { FORCE_COLOR: "3" }, diff --git a/packages/runtime/src/io/write-synchronized.ts b/packages/runtime/src/io/write-synchronized.ts new file mode 100644 index 0000000..8737253 --- /dev/null +++ b/packages/runtime/src/io/write-synchronized.ts @@ -0,0 +1,16 @@ +import type { Writable } from "node:stream"; +import isInCi from "is-in-ci"; + +export const bsu = "\x1b[?2026h"; +export const esu = "\x1b[?2026l"; + +export function shouldSynchronize( + stream: Writable, + interactive?: boolean, +): boolean { + return ( + "isTTY" in stream && + (stream as Writable & { isTTY: boolean }).isTTY && + (interactive ?? !isInCi) + ); +} diff --git a/packages/runtime/src/render.ts b/packages/runtime/src/render.ts index 03baaf1..510ed13 100644 --- a/packages/runtime/src/render.ts +++ b/packages/runtime/src/render.ts @@ -23,6 +23,7 @@ import { createCommitScheduler } from "./scheduler.ts"; import { paint, paintIsolated } from "./paint/paint.ts"; import { findStatics } from "./paint/static-channel.ts"; import { createFrameWriter } from "./io/frame-writer.ts"; +import { bsu, esu, shouldSynchronize } from "./io/write-synchronized.ts"; import { AppContextKey, FocusContextKey, @@ -116,6 +117,7 @@ export interface TuiApp extends Omit, "mount"> { mount(options?: MountOptions): ComponentPublicInstance; waitUntilExit(): Promise; waitUntilRenderFlush(): Promise; + clear(): void; } type RootProps = Record; @@ -171,6 +173,7 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp let mountedScheduler: ReturnType | null = null; let mountedCommit: (() => void) | null = null; let mountedAlternateScreen = false; + let mountedClear: (() => void) | null = null; let mountedKittyController: ReturnType | null = null; // The renderer's onCommit closure is wired at createApp time but only does @@ -204,6 +207,7 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp } function writeBestEffort(stream: NodeJS.WriteStream, data: string) { + if (stream.destroyed || stream.writableEnded) return; try { stream.write(data); } catch { @@ -216,21 +220,22 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp if (teardownStarted) return; teardownStarted = true; - // Final render before unmount (matching Ink ink.tsx:755-761). + // Cancel any pending trailing-edge timer first, then do a final + // synchronous commit so the latest state is always flushed before + // unmount (matching Ink ink.tsx:755-761). // teardownStarted=true makes shouldClearTerminalForFrame see isUnmounting, // so fullscreen apps get clearTerminal on exit. - if (mountedInteractive && !mountedDebug && mountedCommit) { - const skipFinalRender = mountedScheduler?.hasPending(); - if (!skipFinalRender) { - try { - mountedCommit(); - } catch { - // Final render is best-effort; don't block teardown cleanup. - } + scheduledCommit = () => {}; + mountedScheduler?.cancel(); + const stdout = mountedAppContext?.stdout; + const stdoutWritable = stdout && !stdout.destroyed && !stdout.writableEnded; + if (mountedInteractive && !mountedDebug && mountedCommit && stdoutWritable) { + try { + mountedCommit(); + } catch { + // Final render is best-effort; don't block teardown cleanup. } } - - scheduledCommit = () => {}; // Restore console BEFORE Vue cleanup (matching Ink ink.tsx:779) if (mountedRestoreConsole) { mountedRestoreConsole(); @@ -249,7 +254,7 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp // Non-interactive: write the deferred last frame at unmount (matching Ink). const lastFrame = mountedGetLastOutput?.() ?? ""; if (lastFrame) { - mountedAppContext.stdout.write(lastFrame + "\n"); + writeBestEffort(mountedAppContext.stdout, lastFrame + "\n"); } } if (mountedWriter && !mountedDebug && mountedInteractive) mountedWriter.done(); @@ -258,7 +263,7 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp writeBestEffort(mountedAppContext.stdout, "\x1b[?25h"); mountedAlternateScreen = false; } else if (!mountedDebug && mountedInteractive && mountedAppContext) { - mountedAppContext.stdout.write("\x1b[?25h"); + writeBestEffort(mountedAppContext.stdout, "\x1b[?25h"); } if (mountedRoot) detachYoga(mountedRoot); if (mountedResizeHandler && mountedAppContext) { @@ -459,6 +464,12 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp incremental: options.incrementalRendering, }); mountedWriter = writer; + mountedClear = () => { + if (!interactive || debug) return; + writer.clear(); + writer.sync(frameState.lastOutputToRender || frameState.lastOutput + "\n"); + }; + const synchronize = shouldSynchronize(stdout, interactive); function renderInteractiveFrame(output: string, outputHeight: number, staticOutput: string) { const hasStaticOutput = staticOutput !== ""; @@ -480,16 +491,18 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp if (shouldClear) { // Direct write: clearTerminal + accumulated static + raw output - stdout.write(ansiEscapes.clearTerminal + frameState.fullStaticOutput + output); + const content = ansiEscapes.clearTerminal + frameState.fullStaticOutput + output; + stdout.write(synchronize ? bsu + content + esu : content); // Sync log-update state so next render computes correct erase writer.sync(outputToRender); } else if (hasStaticOutput) { // Clear frame -> write static -> re-render frame via log-update + if (synchronize) stdout.write(bsu); writer.clear(); stdout.write(staticOutput); - writer.write(outputToRender); + writer.write(synchronize ? outputToRender + esu : outputToRender); } else { - writer.write(outputToRender); + writer.write(synchronize ? bsu + outputToRender + esu : outputToRender); } frameState.lastOutput = output; @@ -696,6 +709,10 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp }); }; + app.clear = function clear(): void { + mountedClear?.(); + }; + return app; } diff --git a/packages/runtime/src/scheduler.ts b/packages/runtime/src/scheduler.ts index 1bb4b34..cd8bc9e 100644 --- a/packages/runtime/src/scheduler.ts +++ b/packages/runtime/src/scheduler.ts @@ -5,6 +5,8 @@ export interface CommitScheduler { flush: () => Promise; /** Returns true when a trailing-edge commit is pending. */ hasPending: () => boolean; + /** Cancel any pending trailing-edge timer. */ + cancel: () => void; } export interface CommitSchedulerOptions { @@ -89,5 +91,14 @@ export function createCommitScheduler( return hasPendingFlag; } - return { schedule, flush, hasPending }; + function cancel() { + if (trailingTimer) { + clearTimeout(trailingTimer); + trailingTimer = null; + } + hasPendingFlag = false; + scheduled = false; + } + + return { schedule, flush, hasPending, cancel }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a4e88fc..e123d13 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,6 +30,9 @@ catalogs: patch-console: specifier: ^2.0.0 version: 2.0.0 + string-width: + specifier: ^7.2.0 + version: 7.2.0 strip-ansi: specifier: ^7.2.0 version: 7.2.0 @@ -234,9 +237,15 @@ importers: chalk: specifier: 'catalog:' version: 5.6.2 + is-in-ci: + specifier: 'catalog:' + version: 1.0.0 node-pty: specifier: 'catalog:' version: 1.2.0-beta.13 + string-width: + specifier: 'catalog:' + version: 7.2.0 strip-ansi: specifier: 'catalog:' version: 7.2.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0d2f86e..d40e193 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -22,6 +22,7 @@ catalog: node-pty: 1.2.0-beta.13 terminal-size: ^4.0.1 tsx: ^4.22.0 + string-width: ^7.2.0 overrides: vite: "catalog:" vitest: "catalog:"