import { defineComponent, onMounted, onScopeDispose, shallowRef, watchSyncEffect } from "vue"; import chalk from "chalk"; import { describe, test, expect } from "vite-plus/test"; import { renderToString, Box, Text, Newline, Spacer, Static, Transform, useInput, useApp, useFocus, useFocusManager, useStdin, useStdout, useStderr, useCursor, usePaste, useWindowSize, useAnimation, useBoxMetrics, } from "@vue-tui/runtime"; describe("renderToString", () => { test("renders component to string", () => { const App = defineComponent(() => () => ( Hello )); const output = renderToString(App, { columns: 40 }); expect(output).toContain("Hello"); }); test("defaults to 80 columns", () => { const App = defineComponent(() => { return () => test; }); const output = renderToString(App); expect(output).toBe("test"); }); // Contract guard: `isScreenReaderEnabled` is INTERNAL-only — the public `renderToString` must // reject it at the type level (SR rendering goes through `renderToStringWithScreenReader` in // `@vue-tui/runtime/internal`). If the option is ever re-added to the public `RenderToStringOptions`, // the `@ts-expect-error` below goes unused and `tsc --noEmit` fails. (At runtime the unknown option // is harmlessly ignored — only `columns` is read — so the frame still renders.) test("public renderToString rejects the internal isScreenReaderEnabled option (type-level)", () => { const App = defineComponent(() => () => x); // @ts-expect-error - isScreenReaderEnabled is internal-only (use renderToStringWithScreenReader from /internal) const output = renderToString(App, { isScreenReaderEnabled: true }); expect(output).toBe("x"); }); test("rethrows component errors after cleanup", () => { const App = defineComponent(() => { throw new Error("boom"); }); expect(() => renderToString(App)).toThrow("boom"); }); test("renders nested layout", () => { const App = defineComponent(() => () => ( Line 1 Line 2 )); const output = renderToString(App, { columns: 20 }); // Lock the EXACT bytes (Ink render-to-string.tsx: t.is(output, 'Line 1\nLine 2')). expect(output).toBe("Line 1\nLine 2"); }); test("useInput does not throw in renderToString", () => { const App = defineComponent(() => { useInput(() => {}); return () => with input; }); const output = renderToString(App); expect(output).toContain("with input"); }); test("useApp does not throw in renderToString", () => { const App = defineComponent(() => { const { exit } = useApp(); // exit is a function but calling it is a no-op void exit; return () => with exit; }); const output = renderToString(App); expect(output).toContain("with exit"); }); test("useFocus does not throw in renderToString", () => { const App = defineComponent(() => { const { isFocused } = useFocus(); return () => focused: {String(isFocused.value)}; }); const output = renderToString(App); expect(output).toContain("focused:"); }); test("useFocusManager does not throw in renderToString", () => { const App = defineComponent(() => { const fm = useFocusManager(); void fm; return () => with focus manager; }); const output = renderToString(App); expect(output).toContain("with focus manager"); }); test("useStdin does not throw in renderToString", () => { const App = defineComponent(() => { const stdin = useStdin(); void stdin; return () => with stdin; }); const output = renderToString(App); expect(output).toContain("with stdin"); }); test("useStdout does not throw in renderToString", () => { const App = defineComponent(() => { const stdout = useStdout(); void stdout; return () => with stdout; }); const output = renderToString(App); expect(output).toContain("with stdout"); }); test("useStderr does not throw in renderToString", () => { const App = defineComponent(() => { const stderr = useStderr(); void stderr; return () => with stderr; }); const output = renderToString(App); expect(output).toContain("with stderr"); }); test("respects custom columns width", () => { const App = defineComponent(() => () => ( full width )); const narrow = renderToString(App, { columns: 20 }); const wide = renderToString(App, { columns: 60 }); // Both should contain the text expect(narrow).toContain("full width"); expect(wide).toContain("full width"); }); test("renders padding correctly", () => { const App = defineComponent(() => () => ( Padded )); const output = renderToString(App, { columns: 20 }); // Lock the EXACT bytes (Ink render-to-string.tsx: t.is(output, ' Padded')). expect(output).toBe(" Padded"); }); test("rethrows non-Error values as wrapped Error", () => { const App = defineComponent(() => { throw "string error"; }); expect(() => renderToString(App)).toThrow("string error"); }); // ── Text variants ────────────────────────────────────── test("renders text with interpolated variable", () => { const App = defineComponent(() => () => Count: {42}); const output = renderToString(App); expect(output).toBe("Count: 42"); }); test("renders nested text components", () => { const World = defineComponent(() => () => World); const App = defineComponent(() => () => ( Hello )); const output = renderToString(App); expect(output).toBe("Hello World"); }); test("renders empty fragment", () => { const App = defineComponent(() => () => <>); const output = renderToString(App); expect(output).toBe(""); }); test("renders null children", () => { const App = defineComponent(() => () => {null}); const output = renderToString(App); expect(output).toBe(""); }); // ── Layout ───────────────────────────────────────────── test("renders margin", () => { const App = defineComponent(() => () => ( Margined )); const output = renderToString(App); expect(output).toBe(" Margined"); }); test("renders box with fixed width and height", () => { const App = defineComponent(() => () => ( Hi )); const output = renderToString(App); const lines = output.split("\n"); expect(lines.length).toBe(3); }); test("renders box with border", () => { const App = defineComponent(() => () => ( Bordered )); const output = renderToString(App, { columns: 20 }); // Lock the EXACT boxen frame: a 20-wide single border (top corner + 18 ─ + corner, // content row "Bordered" + 10 fill spaces, bottom border). Byte-identical to Ink's // boxen('Bordered', { width: 20, borderStyle: 'single' }) (render-to-string.tsx). expect(output).toBe( "┌──────────────────┐\n" + "│Bordered │\n" + "└──────────────────┘", ); }); test("renders box with flex direction row", () => { const App = defineComponent(() => () => ( A B C )); const output = renderToString(App); expect(output).toBe("ABC"); }); test("renders gap between items", () => { const App = defineComponent(() => () => ( A B )); const output = renderToString(App); expect(output).toBe("A B"); }); // Byte-exact gap variants (Ink gap.tsx). The live render() gap tests use // trimLines:true, which masks trailing-space regressions; the renderToString path // is byte-exact, so these lock the WRAP and COLUMN gaps without that mask. test("renders gap with flexWrap (wraps to a new row separated by a row gap)", () => { const App = defineComponent(() => () => ( A B C )); // Ink: t.is(output, 'A B\n\nC') — "A B" fills width 3, "C" wraps below, the // blank line is the row gap between the two wrapped rows. expect(renderToString(App)).toBe("A B\n\nC"); }); test("renders column gap (blank line between stacked items)", () => { const App = defineComponent(() => () => ( A B )); // Ink: t.is(output, 'A\n\nB') expect(renderToString(App)).toBe("A\n\nB"); }); test("renders spacer pushing content apart", () => { const App = defineComponent(() => () => ( Left Right )); const output = renderToString(App); expect(output).toBe("Left Right"); }); test("renders newline inserting blank line", () => { const App = defineComponent(() => () => ( Above Below )); const output = renderToString(App); expect(output).toBe("Above\n\n\nBelow"); }); // ── Styling ──────────────────────────────────────────── test("renders colored text", () => { const App = defineComponent(() => () => Green); const output = renderToString(App); expect(output).toBe(chalk.green("Green")); }); test("renders bold text", () => { const App = defineComponent(() => () => Bold); const output = renderToString(App); expect(output).toBe(chalk.bold("Bold")); }); // ── Text wrapping and columns ───────────────────────── test("renders text with wrap", () => { const App = defineComponent(() => () => ( Hello World )); const output = renderToString(App); expect(output).toBe("Hello\nWorld"); }); test("renders text with truncate", () => { const App = defineComponent(() => () => ( Hello World )); const output = renderToString(App); expect(output).toBe("Hello …"); }); test("default columns wraps text at 80", () => { const longText = "A".repeat(100); const App = defineComponent(() => () => {longText}); const output = renderToString(App); const lines = output.split("\n"); expect(lines.length).toBe(2); expect(lines[0]).toBe("A".repeat(80)); expect(lines[1]).toBe("A".repeat(20)); }); test("custom columns option", () => { const longText = "A".repeat(50); const App = defineComponent(() => () => {longText}); const output = renderToString(App, { columns: 30 }); const lines = output.split("\n"); expect(lines.length).toBe(2); expect(lines[0]).toBe("A".repeat(30)); expect(lines[1]).toBe("A".repeat(20)); }); // ── Components ───────────────────────────────────────── test("renders Transform component", () => { const App = defineComponent(() => () => ( output.toUpperCase()}> hello )); const output = renderToString(App); expect(output).toBe("HELLO"); }); test("renders Static component with items", () => { const items = ["A", "B", "C"]; const App = defineComponent(() => () => ( {{ default: ({ item }: { item: string }) => {item} }} Dynamic )); const output = renderToString(App); expect(output).toBe("A\nB\nC\nDynamic"); }); test("render static-only output has no trailing newline", () => { const items = ["A", "B"]; const App = defineComponent(() => () => ( {{ default: ({ item }: { item: string }) => {item} }} )); const output = renderToString(App); expect(output).toBe("A\nB"); }); test("render static + dynamic output has exactly one newline between parts", () => { const items = ["A", "B"]; const App = defineComponent(() => () => ( {{ default: ({ item }: { item: string }) => {item} }} Dynamic )); const output = renderToString(App); expect(output).toBe("A\nB\nDynamic"); }); // ── Effect behavior ──────────────────────────────────── test("captures initial render before onMounted state updates", () => { const App = defineComponent(() => { const text = shallowRef("Initial"); onMounted(() => { text.value = "Mounted"; }); return () => {text.value}; }); const output = renderToString(App); expect(output).toBe("Initial"); }); test("watchSyncEffect state updates are reflected in output", () => { const App = defineComponent(() => { const text = shallowRef("Initial"); // watchSyncEffect runs synchronously during setup, analogous to // React's useLayoutEffect — state updates are flushed before paint. watchSyncEffect(() => { text.value = "Sync Updated"; }); return () => {text.value}; }); const output = renderToString(App); expect(output).toBe("Sync Updated"); }); test("runs onScopeDispose cleanup on teardown", () => { let cleanupRan = false; const App = defineComponent(() => { onScopeDispose(() => { cleanupRan = true; }); return () => Cleanup test; }); const output = renderToString(App); expect(output).toBe("Cleanup test"); expect(cleanupRan).toBe(true); }); // ── Error handling ───────────────────────────────────── test("text outside Text component throws", () => { const App = defineComponent(() => () => {"raw text"}); expect(() => renderToString(App)).toThrow(/must be rendered inside /); }); test("subsequent calls work after a component error", () => { const Broken = defineComponent(() => { throw new Error("Boom"); }); expect(() => renderToString(Broken)).toThrow(); const Ok = defineComponent(() => () => Still works); const output = renderToString(Ok); expect(output).toBe("Still works"); }); // ── Independence ─────────────────────────────────────── test("can be called multiple times independently", () => { const First = defineComponent(() => () => First); const Second = defineComponent(() => () => Second); const output1 = renderToString(First); const output2 = renderToString(Second); expect(output1).toBe("First"); expect(output2).toBe("Second"); }); // ── Deeply nested tree ──────────────────────────────── test("renders deeply nested component tree", () => { const App = defineComponent(() => () => ( {"Nested "} deep )); const output = renderToString(App); expect(output).toContain("Nested"); expect(output).toContain("deep"); }); // ── Nested inherits ancestor styles (Ink wrapping model) ───────── // // Ink composes nested by WRAPPING: squash-text-nodes.ts concatenates a // node's already-styled children, then the PARENT Text's internal_transform // wraps the WHOLE concatenation. So a parent's boolean styles (bold/italic/ // underline/strikethrough/dim) stay OPEN across a nested child — the child only // ADDS its own style on top. The Ink composition is literally // `chalk.