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.