fix(runtime): unknown borderStyle string throws (Ink parity) (#124)
An unknown borderStyle name (reachable only via a TS-bypass — the prop type is the cli-boxes keyof union) silently degraded to no border (and wrongly reserved a 1-cell inset). Ink throws a TypeError on it. Align to that "throw on unknown" contract by validating in the Box component's render, so the throw is caught by vue-tui's error boundary (onErrorCaptured -> ErrorOverview), like any other render error — rather than in paint, where a throw would unwind through Vue's post-flush commit and wedge the scheduler. The check resolves cliBoxes[borderStyle] and throws unless it's a genuine BoxStyle (an object with a string `top` glyph), so unknown names, the cli-boxes CJS-interop `default` self-key, and inherited prototype names (toString, constructor) all throw; valid names, false/undefined (no border), and a custom BoxStyle object are unaffected. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1385,6 +1385,141 @@ test("G16: per-edge borderDimColor=false overrides general borderDimColor", asyn
|
||||
expect(bottomLine).toContain("[2m");
|
||||
});
|
||||
|
||||
// audit 2.3 — an UNKNOWN borderStyle string THROWS (Ink parity), rather than
|
||||
// silently degrading to no border (the old vue-tui behavior, which also wrongly
|
||||
// reserved a 1-cell inset). Ink's render-border.ts has no existence check: it
|
||||
// reads box.topLeft/box.top off `cliBoxes[name]` === undefined and crashes with a
|
||||
// TypeError. We align by throwing a clear, descriptive Error — but do it in the
|
||||
// Box component's RENDER (Box.ts), so the throw is caught by vue-tui's existing
|
||||
// error boundary (onErrorCaptured → ErrorOverview → exit), exactly like any other
|
||||
// component render error. paint.ts stays a silent `if (!chars) return` fallback
|
||||
// (a raw throw in the post-flush commit would wedge Vue's scheduler).
|
||||
//
|
||||
// OBSERVED behavior: the error boundary routes the thrown Error through exit(),
|
||||
// which REJECTS waitUntilExit(); @vue-tui/testing's render() surfaces that
|
||||
// rejection (its earlyError detector rethrows before returning a result), so
|
||||
// render() itself REJECTS with the message. The ErrorOverview frame is rendered
|
||||
// internally but never reaches the caller — render() never resolves a result.
|
||||
// (TS-bypass via `as any`: the public prop type is the cli-boxes keyof union, so
|
||||
// an unknown name is reachable only by escaping the type system.)
|
||||
test("UNKNOWN borderStyle throws (Ink parity)", async ({ expect }) => {
|
||||
await expect(
|
||||
render(
|
||||
defineComponent(() => () => (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<Box borderStyle={"definitely-not-a-real-style" as any} alignSelf="flex-start">
|
||||
<Text>Hi</Text>
|
||||
</Box>
|
||||
)),
|
||||
{ columns: 100 },
|
||||
),
|
||||
).rejects.toThrow(/Unknown borderStyle/);
|
||||
});
|
||||
|
||||
// A bare `in cliBoxes` membership check has two false-accept holes that these
|
||||
// guard against:
|
||||
// 1. cli-boxes' CJS-interop `default` self-key — `"default" in cliBoxes` is true,
|
||||
// but cliBoxes.default is the WHOLE boxes object, not a BoxStyle (no string
|
||||
// `.top`). Paint would then read `.top`/`.topLeft` off it → garbage/crash.
|
||||
// 2. `in` walks the prototype chain, so Object.prototype members like
|
||||
// "toString"/"constructor"/"hasOwnProperty" report as "in cliBoxes" while
|
||||
// resolving to a function/undefined — never a real BoxStyle.
|
||||
// A shape check (resolved value is an object with a string `top`) rejects all of
|
||||
// these. Each name must THROW exactly like any other unknown style.
|
||||
for (const badName of ["default", "toString", "constructor", "hasOwnProperty"]) {
|
||||
test(`borderStyle ${JSON.stringify(badName)} (in-cliBoxes false-accept) throws`, async ({
|
||||
expect,
|
||||
}) => {
|
||||
await expect(
|
||||
render(
|
||||
defineComponent(() => () => (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<Box borderStyle={badName as any} alignSelf="flex-start">
|
||||
<Text>Hi</Text>
|
||||
</Box>
|
||||
)),
|
||||
{ columns: 100 },
|
||||
),
|
||||
).rejects.toThrow(/Unknown borderStyle/);
|
||||
});
|
||||
}
|
||||
|
||||
// Guard the negative cases that MUST NOT throw — only a non-empty unknown STRING
|
||||
// does. The Box-render check skips any falsy borderStyle (false/undefined/"" =
|
||||
// "no border"), every valid cli-boxes preset name, and a custom BoxStyle OBJECT
|
||||
// (Ink types borderStyle as `keyof Boxes | BoxStyle`). We assert each renders
|
||||
// normally (no rejection from render()) and that a valid style/object actually
|
||||
// draws border glyphs while a falsy one draws none. (`false` is reachable only
|
||||
// via a TS-bypass — Ink's type has no `false`.)
|
||||
test("non-throwing borderStyle (false/undefined/valid/object) renders normally", async ({
|
||||
expect,
|
||||
}) => {
|
||||
const falseResult = await render(
|
||||
defineComponent(() => () => (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<Box borderStyle={false as any} alignSelf="flex-start">
|
||||
<Text>Hi</Text>
|
||||
</Box>
|
||||
)),
|
||||
{ columns: 100 },
|
||||
);
|
||||
const falseFrame = stripAnsi(falseResult.lastFrame()!);
|
||||
expect(falseFrame).toContain("Hi");
|
||||
// No box-drawing glyphs of any border style appear.
|
||||
expect(falseFrame).not.toMatch(/[╭╮╰╯─│┌┐└┘╔╗╚╝═║↘↗↖↙]/);
|
||||
|
||||
const undefinedResult = await render(
|
||||
defineComponent(() => () => (
|
||||
<Box borderStyle={undefined} alignSelf="flex-start">
|
||||
<Text>Hi</Text>
|
||||
</Box>
|
||||
)),
|
||||
{ columns: 100 },
|
||||
);
|
||||
const undefinedFrame = stripAnsi(undefinedResult.lastFrame()!);
|
||||
expect(undefinedFrame).toContain("Hi");
|
||||
expect(undefinedFrame).not.toMatch(/[╭╮╰╯─│┌┐└┘╔╗╚╝═║↘↗↖↙]/);
|
||||
|
||||
// A valid preset name renders its border (here `round` → ╭╮╰╯), no throw.
|
||||
const validResult = await render(
|
||||
defineComponent(() => () => (
|
||||
<Box borderStyle="round" alignSelf="flex-start">
|
||||
<Text>Hi</Text>
|
||||
</Box>
|
||||
)),
|
||||
{ columns: 100 },
|
||||
);
|
||||
const validFrame = stripAnsi(validResult.lastFrame()!);
|
||||
expect(validFrame).toContain("Hi");
|
||||
expect(validFrame).toMatch(/[╭╮╰╯]/);
|
||||
|
||||
// A custom BoxStyle OBJECT is valid (Ink parity G13) and must NOT throw — the
|
||||
// check only fires for an unknown STRING. The object's own glyphs render.
|
||||
const customStyle = {
|
||||
topLeft: "A",
|
||||
top: "B",
|
||||
topRight: "C",
|
||||
left: "D",
|
||||
right: "E",
|
||||
bottomLeft: "F",
|
||||
bottom: "G",
|
||||
bottomRight: "H",
|
||||
};
|
||||
const objectResult = await render(
|
||||
defineComponent(() => () => (
|
||||
<Box borderStyle={customStyle} alignSelf="flex-start">
|
||||
<Text>Hi</Text>
|
||||
</Box>
|
||||
)),
|
||||
{ columns: 100 },
|
||||
);
|
||||
const objectFrame = stripAnsi(objectResult.lastFrame()!);
|
||||
expect(objectFrame).toContain("Hi");
|
||||
// Corner glyphs from the custom object are drawn.
|
||||
expect(objectFrame).toContain("A");
|
||||
expect(objectFrame).toContain("H");
|
||||
});
|
||||
|
||||
// borderDimColor should not dim styled child Text touching left edge
|
||||
test("borderDimColor does not dim styled child Text touching left edge", async ({ expect }) => {
|
||||
const { lastFrame } = await render(
|
||||
|
||||
Reference in New Issue
Block a user