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:
@@ -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(`
|
||||
"[41mHi [49m
|
||||
[41m [49m"
|
||||
`);
|
||||
});
|
||||
|
||||
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(`"[41mHi[49m"`);
|
||||
});
|
||||
|
||||
// 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(`
|
||||
"[31m┌──┐[39m
|
||||
[31m│[39m [31m│[39m
|
||||
[31m└──┘[39m"
|
||||
`);
|
||||
});
|
||||
|
||||
// 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(`
|
||||
"[41m┌──┐[49m
|
||||
[41m│[49m [41m│[49m
|
||||
[41m└──┘[49m"
|
||||
`);
|
||||
});
|
||||
|
||||
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]>();
|
||||
|
||||
Reference in New Issue
Block a user