fix(runtime): align Ink parity behavior

Align several user-observable runtime behaviors with the Ink v7.0.4 parity audit: live input/paste handler refs, duplicate focus id registration, string-only color props, noninteractive empty final newlines, cross-realm error headers, and contained zero-content box layout/paint.

Document Vue-specific KEEP decisions and require Conventional Commits for commit messages and PR titles.

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-06-08 12:51:20 +08:00
committed by GitHub
parent 1340c049b2
commit 1fd832d297
25 changed files with 971 additions and 332 deletions
@@ -1,4 +1,4 @@
import { defineComponent, shallowRef, nextTick } from "vue";
import { defineComponent, shallowRef, nextTick, h } from "vue";
import { test } from "vite-plus/test";
import { render } from "@vue-tui/testing";
import { Box, Text, renderToString } from "@vue-tui/runtime";
@@ -802,8 +802,43 @@ test("backgroundColor of an unknown non-chalk string degrades to bare text (no t
expect(lastFrame()).toBe("Hi");
});
// MUST NOT throw: hex / ansi256 / rgb / [r,g,b] backgrounds are valid forms.
test("backgroundColor hex / ansi256 / rgb / [r,g,b] do NOT throw", async ({ expect }) => {
test("non-string host Box backgroundColor does not override inherited background", async ({
expect,
}) => {
const { lastFrame } = await render(
defineComponent(
() => () =>
h("box", { backgroundColor: "red", width: 5, height: 2 }, [
h("box", { backgroundColor: [0, 0, 255], width: 5, height: 2 }, [h("text", null, "Hi")]),
]),
),
{ columns: 100 },
);
expect(lastFrame()).toMatchInlineSnapshot(`
"Hi 
 "
`);
});
test("non-string host Text backgroundColor does not override inherited background", async ({
expect,
}) => {
const { lastFrame } = await render(
defineComponent(
() => () =>
h("box", { backgroundColor: "red", alignSelf: "flex-start" }, [
h("text", { backgroundColor: [0, 0, 255] }, "Hi"),
]),
),
{ columns: 100 },
);
expect(lastFrame()).toMatchInlineSnapshot(`"Hi"`);
});
// MUST NOT throw: hex / ansi256 / rgb(...) string backgrounds are valid Ink forms.
test("backgroundColor hex / ansi256 / rgb string do NOT throw", async ({ expect }) => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box flexDirection="column" alignSelf="flex-start">
@@ -816,16 +851,13 @@ test("backgroundColor hex / ansi256 / rgb / [r,g,b] do NOT throw", async ({ expe
<Box backgroundColor="rgb(1, 2, 3)">
<Text>C</Text>
</Box>
<Box backgroundColor={[4, 5, 6]}>
<Text>D</Text>
</Box>
</Box>
)),
{ columns: 100 },
);
const out = lastFrame()!;
expect(out).toContain("A");
expect(out).toContain("D");
expect(out).toContain("C");
});
// MUST NOT throw: foreground `color="bold"` resolves `chalk.bold` (a real fn) and
@@ -923,6 +955,32 @@ test("bad general borderBackgroundColor with valid per-edge on every drawn edge
expect(lastFrame()).toContain("Hi");
});
test("non-string border edge background does not suppress invalid general fallback", async ({
expect,
}) => {
// Deliberately bypass the public string type to exercise runtime JS input.
const legacyTuple = [0, 0, 255] as unknown as string;
await expect(
render(
defineComponent(
() => () =>
h(Box, {
borderStyle: "single",
borderBackgroundColor: "bold",
borderTopBackgroundColor: legacyTuple,
borderBottom: false,
borderLeft: false,
borderRight: false,
width: 4,
height: 1,
}),
),
{ columns: 100 },
),
).rejects.toThrow(/borderTopBackgroundColor/i);
});
// Empty <Text backgroundColor="bold">{""}</Text>: Ink's Text returns null for
// empty children BEFORE attaching its colorizing transform, so colorize never
// runs. vue-tui validates AFTER the empty early-return, so this must NOT throw.
@@ -1,4 +1,4 @@
import { defineComponent, shallowRef, nextTick } from "vue";
import { defineComponent, shallowRef, nextTick, h } from "vue";
import { test } from "vite-plus/test";
import { render } from "@vue-tui/testing";
import { Box, Text } from "@vue-tui/runtime";
@@ -880,6 +880,28 @@ test("change color of top border", async ({ expect }) => {
`);
});
test("non-string host borderTopColor falls back to general borderColor", async ({ expect }) => {
const { lastFrame } = await render(
defineComponent(
() => () =>
h("box", {
borderStyle: "single",
borderColor: "red",
borderTopColor: [0, 0, 255],
width: 4,
height: 3,
}),
),
{ columns: 100 },
);
expect(lastFrame()).toMatchInlineSnapshot(`
"┌──┐
│ │
└──┘"
`);
});
// change color of bottom border
test("change color of bottom border", async ({ expect }) => {
const { lastFrame } = await render(
@@ -1161,6 +1183,30 @@ test("border background color fallback to general borderBackgroundColor", async
expect(frame).toContain("[45m");
});
test("non-string host borderTopBackgroundColor falls back to general borderBackgroundColor", async ({
expect,
}) => {
const { lastFrame } = await render(
defineComponent(
() => () =>
h("box", {
borderStyle: "single",
borderBackgroundColor: "red",
borderTopBackgroundColor: [0, 0, 255],
width: 4,
height: 3,
}),
),
{ columns: 100 },
);
expect(lastFrame()).toMatchInlineSnapshot(`
"┌──┐
│ │
└──┘"
`);
});
test("vertical border background does not bleed into content rows", async ({ expect }) => {
const { lastFrame } = await render(
defineComponent(() => () => (
@@ -346,13 +346,10 @@ test("hard wrap with long word", async () => {
expect(lastFrame()).toBe("aaaaa\naaaaa");
});
test("hard wrap at width 0 measures one row per grapheme PLUS a blank row per interior word boundary (Ink parity)", async () => {
// Ink wrap-text.ts uses wordWrap:false for `hard` mode: at width 0 that inserts an extra
// blank row before each interior word's first grapheme, so "a b c" measures height 8
// (["","a"," ","","b"," ","","c"]) — NOT the height-6 `wrap`-mode layout. The 0-width
// Box is the tallest child, so the row sibling "X" sits on the FIRST row, and the column
// grows to 8 rows. If `hard` were (wrongly) measured with `wrap` structure, the box would
// be 6 rows tall.
test("hard wrap inside a zero-width Box does not reserve invisible child rows", async () => {
// A Box whose resolved inner content width is 0 has no legal child paint area.
// The child text should therefore neither paint nor inflate the row height, even
// though Ink's zero-width hard-wrap path produces extra invisible rows.
const { lastFrame } = await render(
defineComponent(() => () => (
<Box flexDirection="row">
@@ -365,11 +362,7 @@ test("hard wrap at width 0 measures one row per grapheme PLUS a blank row per in
{ columns: 100 },
);
const lines = stripAnsi(lastFrame()!).split("\n");
// 8 rows total (the 0-width hard-wrapped Text dictates the column height).
expect(lines.length).toBe(8);
// The 0-width column contributes no visible columns, so each row is just the sibling's
// contribution on row 0 ("X") and empty rows below — confirming height 8, not 6.
expect(lines[0]).toContain("X");
expect(lines).toEqual(["X"]);
});
test("don't hard wrap text if there is enough space", async () => {
@@ -1,4 +1,4 @@
import { defineComponent, shallowRef } from "vue";
import { defineComponent, nextTick, shallowRef, toRef, type PropType } from "vue";
import { expect, test, vi } from "vite-plus/test";
import { render } from "@vue-tui/testing";
import { Text, useInput, useStdout, type Key } from "@vue-tui/runtime";
@@ -44,6 +44,39 @@ test("useInput respects isActive ref", async () => {
expect(calls).toEqual(["b"]);
});
test("useInput accepts a handler ref and calls the latest function", async () => {
const calls: string[] = [];
const firstHandler = (input: string) => calls.push(`first:${input}`);
const secondHandler = (input: string) => calls.push(`second:${input}`);
const currentHandler = shallowRef(firstHandler);
const Child = defineComponent({
props: {
onInput: {
type: Function as PropType<(input: string, key: Key) => void>,
required: true,
},
},
setup(props) {
useInput(toRef(props, "onInput"));
return () => <Text>child</Text>;
},
});
const App = defineComponent(() => {
return () => <Child onInput={currentHandler.value} />;
});
const { stdin } = await render(App);
await stdin.write("a");
expect(calls).toEqual(["first:a"]);
currentHandler.value = secondHandler;
await nextTick();
await stdin.write("b");
expect(calls).toEqual(["first:a", "second:b"]);
});
test("two useInput hooks both receive the same input", async () => {
const a: string[] = [];
const b: string[] = [];
@@ -1,4 +1,4 @@
import { defineComponent, shallowRef } from "vue";
import { defineComponent, nextTick, shallowRef, toRef, type PropType } from "vue";
import { describe, test, expect } from "vite-plus/test";
import { render } from "@vue-tui/testing";
import { Text, useInput, usePaste } from "@vue-tui/runtime";
@@ -52,6 +52,39 @@ describe("usePaste", () => {
expect(pasted.value).toBe("captured");
});
test("accepts a handler ref and calls the latest function", async () => {
const calls: string[] = [];
const firstHandler = (text: string) => calls.push(`first:${text}`);
const secondHandler = (text: string) => calls.push(`second:${text}`);
const currentHandler = shallowRef(firstHandler);
const Child = defineComponent({
props: {
onPaste: {
type: Function as PropType<(text: string) => void>,
required: true,
},
},
setup(props) {
usePaste(toRef(props, "onPaste"));
return () => <Text>child</Text>;
},
});
const App = defineComponent(() => {
return () => <Child onPaste={currentHandler.value} />;
});
const { stdin } = await render(App);
await stdin.write("\x1b[200~alpha\x1b[201~");
expect(calls).toEqual(["first:alpha"]);
currentHandler.value = secondHandler;
await nextTick();
await stdin.write("\x1b[200~beta\x1b[201~");
expect(calls).toEqual(["first:alpha", "second:beta"]);
});
test("usePaste intercepts paste so useInput does not receive it", async () => {
const inputReceived: string[] = [];
const pasteReceived: string[] = [];
@@ -175,6 +175,73 @@ test("useFocusManager().activeId updates on programmatic focus(id)", async () =>
expect(activeId.value).toBe("first");
});
test("duplicate explicit focus ids participate in focus order like Ink", async () => {
let activeId!: ReturnType<typeof useFocusManager>["activeId"];
const Item = defineComponent({
props: {
id: { type: String, required: true },
label: { type: String, required: true },
autoFocus: Boolean,
},
setup(props) {
const { isFocused } = useFocus({
id: props.id,
autoFocus: props.autoFocus,
});
return () => (
<Text>
{isFocused.value ? "▶ " : " "}
{props.label}
</Text>
);
},
});
const App = defineComponent(() => {
activeId = useFocusManager().activeId;
return () => (
<Box flexDirection="column">
<Item id="dup" label="first duplicate" autoFocus />
<Item id="dup" label="second duplicate" />
<Item id="next" label="next" />
</Box>
);
});
const { lastFrame, stdin } = await render(App);
expect(activeId.value).toBe("dup");
expect(lastFrame()).toContain("▶ first duplicate");
expect(lastFrame()).toContain("▶ second duplicate");
await stdin.write("\t");
// Ink keeps duplicate explicit ids as separate registry entries. Moving from
// the first duplicate to the second duplicate leaves the public activeId
// unchanged, so both components with that id still report focused.
expect(activeId.value).toBe("dup");
expect(lastFrame()).toContain("▶ first duplicate");
expect(lastFrame()).toContain("▶ second duplicate");
expect(lastFrame()).not.toContain("▶ next");
await stdin.write("\t");
// The duplicate ids are two registry entries, so the next Tab advances past
// the second duplicate to the following distinct focusable.
expect(activeId.value).toBe("next");
expect(lastFrame()).not.toContain("▶ first duplicate");
expect(lastFrame()).not.toContain("▶ second duplicate");
expect(lastFrame()).toContain("▶ next");
await stdin.write("\x1b[Z");
expect(activeId.value).toBe("dup");
expect(lastFrame()).toContain("▶ first duplicate");
expect(lastFrame()).toContain("▶ second duplicate");
expect(lastFrame()).not.toContain("▶ next");
});
// LOCK: unmounting the focused item resets activeId. Mirrors Ink focus.tsx:708-742
// ("activeId resets to undefined when focused component unmounts"). Vue uses a
// v-if (`show`) toggle in place of Ink's rerender-without-the-child.
@@ -207,13 +207,9 @@ test("non-number/non-string flexBasis falls back to auto (Ink parity), does not
expect(lastFrame({ trimLines: true })).toBe("AB");
});
// A zero/negative parsed percent ("0"→0%, "-5"→-5%, "0x10"→parseInt=0→0%) produces a
// 0-width inner box. Ink renders "B\nA" (B on the row, A wraps onto the next line). The
// 0-width text measures via wrapAnsi("A", 0, {hard:true, trim:false}) = "\nA" → height 2,
// so A occupies a second row. vue previously dropped the text ("B") because wrapText's
// `width <= 0 → [""]` guard collapsed the measure to height 1. Verified against Ink v7.0.4
// (@40b3a75): all four of flexBasis=0/"0%" and width=0/"0%" render "B\nA".
test("zero/negative flexBasis% wraps the sibling in Ink (downstream divergence)", async () => {
// A zero-width inner content rect has no legal child paint area. Children must
// neither paint nor reserve the extra rows Ink's zero-width wrapping creates.
test("zero flexBasis hides children and does not reserve invisible rows", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box flexDirection="row" width={6}>
@@ -225,11 +221,10 @@ test("zero/negative flexBasis% wraps the sibling in Ink (downstream divergence)"
)),
{ columns: 100 },
);
// Ink v7.0.4 renders "B\nA".
expect(lastFrame({ trimLines: true })).toBe("B\nA");
expect(lastFrame({ trimLines: true })).toBe("B");
});
test("zero-width Box wraps its text onto its own line (width={0})", async () => {
test("zero-width Box hides children and does not reserve invisible rows (width={0})", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box width={6}>
@@ -241,12 +236,10 @@ test("zero-width Box wraps its text onto its own line (width={0})", async () =>
)),
{ columns: 100 },
);
// Ink v7.0.4 renders "B\nA": the 0-width text measures height 2 via
// wrapAnsi("A", 0, {hard:true}) = "\nA", so A wraps below sibling B.
expect(lastFrame({ trimLines: true })).toBe("B\nA");
expect(lastFrame({ trimLines: true })).toBe("B");
});
test('zero-percent-width Box wraps its text onto its own line (width="0%")', async () => {
test('zero-percent-width Box hides children and does not reserve invisible rows (width="0%")', async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box width={6}>
@@ -258,8 +251,7 @@ test('zero-percent-width Box wraps its text onto its own line (width="0%")', asy
)),
{ columns: 100 },
);
// Ink v7.0.4 renders "B\nA" — same as width={0}; a 0% resolved width is also 0px.
expect(lastFrame({ trimLines: true })).toBe("B\nA");
expect(lastFrame({ trimLines: true })).toBe("B");
});
test("zero-width Box with EMPTY text adds no spurious row", async () => {
@@ -279,14 +271,7 @@ test("zero-width Box with EMPTY text adds no spurious row", async () => {
expect(lastFrame({ trimLines: true })).toBe("B");
});
test("zero-width Box with backgroundColor wraps cleanly, keeping the bg glyph (Ink parity)", async () => {
// Regression guard for the wrap-ansi width<=0 byte-split: at width 0 the 0-width Box's
// text wraps onto its own row, but vue bakes the bg color INTO the string before wrapping,
// and wrap-ansi@10 byte-splits the SGR escapes of a STYLED string at width<=0
// (wrapAnsi("\x1b[41mA\x1b[49m", 0) = "\x1b\n[\n4\n1\nm\nA\n…"). That scattered the escape
// bytes across rows and rendered a garbage "B\n[" (the 2nd byte of "\x1b[41m"). wrapText
// now routes width<=0 styled text through an ANSI-aware per-grapheme split, matching Ink,
// which wraps PLAIN text and colorizes per line afterwards.
test("zero-width Box with backgroundColor hides children and does not reserve invisible rows", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box flexDirection="row" width={6}>
@@ -298,13 +283,22 @@ test("zero-width Box with backgroundColor wraps cleanly, keeping the bg glyph (I
)),
{ columns: 100 },
);
// RAW-byte parity target captured from Ink v7.0.4 (@40b3a75) with chalk level 3:
// "B\n\x1b[41mA\x1b[49m\n" — row 2 keeps the FULL bg-colored glyph (overflow:visible).
// vue trims trailing whitespace/newlines per frame line, so the equivalent raw frame is
// "B\n\x1b[41mA\x1b[49m" (no trailing newline). The bg glyph must survive intact.
expect(lastFrame({ raw: true })).toBe("B\n\x1b[41mA\x1b[49m");
// And the stripped visible layout is "B\nA" (sanity check on the wrap position).
// eslint-disable-next-line no-control-regex -- strip ANSI to assert the visible layout
const visible = lastFrame({ trimLines: true })!.replace(/\x1b\[[0-9;]*m/g, "");
expect(visible).toBe("B\nA");
expect(lastFrame({ raw: true })).toBe("B");
});
test("zero-width Box hides nested Box children and does not reserve invisible rows", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box flexDirection="row" width={6}>
<Box width={0}>
<Box borderStyle="single">
<Text>A</Text>
</Box>
</Box>
<Text>B</Text>
</Box>
)),
{ columns: 100 },
);
expect(lastFrame({ trimLines: true })).toBe("B");
});
@@ -64,3 +64,55 @@ test("text wraps within border+padding content area", async () => {
expect(frame).toContain("Hello");
expect(frame).toContain("World!");
});
test("children are not painted when border consumes content height", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box borderStyle="single" width={3} height={2}>
<Text>x</Text>
</Box>
)),
{ columns: 20 },
);
expect(lastFrame()).toBe("┌─┐\n└─┘");
});
test("children are not painted when border consumes content width", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box borderStyle="single" width={2} height={3}>
<Text>x</Text>
</Box>
)),
{ columns: 20 },
);
expect(lastFrame()).toBe("┌┐\n││\n└┘");
});
test("children are not painted when padding consumes the remaining content area", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box borderStyle="single" width={4} height={3} paddingX={1}>
<Text>x</Text>
</Box>
)),
{ columns: 20 },
);
expect(lastFrame()).toBe("┌──┐\n│ │\n└──┘");
});
test("children are not painted inside a zero-height side-only border box", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box borderStyle="single" borderTop={false} borderBottom={false} height={0}>
<Text>x</Text>
</Box>
)),
{ columns: 20 },
);
expect(lastFrame()).toBe("");
});
@@ -113,6 +113,21 @@ test("nested component throw renders a frame containing ERROR and the message",
expect(frame).toContain("Nested component error");
});
test("cross-realm Error overview header renders message without Error prefix", async () => {
const vm = await import("node:vm");
const foreignError = vm.runInNewContext("new Error('boom')") as Error;
const CrossRealmThrower = defineComponent(() => {
return () => {
throw foreignError;
};
});
const frame = await renderErrorFrame(CrossRealmThrower);
expect(frame).toContain(" ERROR boom");
expect(frame).not.toContain(" ERROR Error: boom");
});
test("unparsable stack frame falls back to literal backslash-t (not a real TAB)", async () => {
const frame = await renderErrorFrame(UnparsableStackThrower);
@@ -88,6 +88,41 @@ test("non-interactive mode writes only last frame at unmount", async () => {
expect(postUnmountOutput).toContain("the-content");
});
test("non-interactive empty final frame still writes trailing newline at unmount", async () => {
// Ink writes `lastOutput + "\n"` during non-interactive teardown even when
// `lastOutput` is empty. This is observable in scripts/pipes as a final newline.
const App = defineComponent(() => () => null);
const stdout = makeFakeWritable({ columns: 80 });
const stderr = makeFakeWritable({ columns: 80 });
const { stream: stdin } = makeFakeStdin();
(stdout as unknown as { isTTY: boolean }).isTTY = false;
const chunks: string[] = [];
(stdout as unknown as PassThrough).on("data", (chunk: Buffer) => {
chunks.push(chunk.toString());
});
const app = createApp(App);
app.mount({
stdout,
stdin,
stderr,
exitOnCtrlC: false,
interactive: false,
});
await nextTick();
await nextTick();
expect(chunks.join("")).toBe("");
app.unmount();
expect(chunks.join("")).toBe("\n");
});
test("non-interactive unmount skips final frame when stdout is not writable", async () => {
const App = defineComponent(() => () => <Text>the-content</Text>);
@@ -12,13 +12,15 @@
// `check:type` script). This file is named `*.test-d.ts` on purpose so vitest does NOT
// pick it up as a runtime test (its include is `*.test.ts`), while tsc still checks it.
import { expectTypeOf } from "vite-plus/test";
import { useApp, useStdin, useStdout, useStderr } from "@vue-tui/runtime";
import { shallowRef } from "vue";
import { useApp, useInput, usePaste, useStdin, useStdout, useStderr } from "@vue-tui/runtime";
import type {
BoxProps,
TextProps,
StaticProps,
TransformProps,
NewlineProps,
Key,
WindowSize,
CursorPosition,
UseAppReturn,
@@ -33,6 +35,11 @@ expectTypeOf<BoxProps["flexDirection"]>().toEqualTypeOf<
>();
expectTypeOf<BoxProps["gap"]>().toEqualTypeOf<number | undefined>();
expectTypeOf<TextProps["bold"]>().toEqualTypeOf<boolean | undefined>();
expectTypeOf<TextProps["color"]>().toEqualTypeOf<string | undefined>();
expectTypeOf<TextProps["backgroundColor"]>().toEqualTypeOf<string | undefined>();
expectTypeOf<BoxProps["backgroundColor"]>().toEqualTypeOf<string | undefined>();
expectTypeOf<BoxProps["borderColor"]>().toEqualTypeOf<string | undefined>();
expectTypeOf<BoxProps["borderBackgroundColor"]>().toEqualTypeOf<string | undefined>();
expectTypeOf<StaticProps["items"]>().toEqualTypeOf<unknown[]>();
expectTypeOf<TransformProps["transform"]>().toEqualTypeOf<
(line: string, lineIndex: number) => string
@@ -75,3 +82,9 @@ expectTypeOf<UseAppReturn>().toEqualTypeOf<{
readonly waitUntilRenderFlush: () => Promise<void>;
}>();
expectTypeOf<ReturnType<typeof useApp>>().toEqualTypeOf<UseAppReturn>();
const inputHandler = shallowRef((_input: string, _key: Key) => {});
expectTypeOf(inputHandler).toMatchTypeOf<Parameters<typeof useInput>[0]>();
const pasteHandler = shallowRef((_text: string) => {});
expectTypeOf(pasteHandler).toMatchTypeOf<Parameters<typeof usePaste>[0]>();