test(runtime): lock reconciler, measure, flex, overflow, build-output (Ink parity) (#116)

Final round-2 test-only batch (behaviors already at parity with Ink reconciler.tsx,
measure-text.tsx, flex-*.tsx, overflow.tsx, build-output.ts):
- build-output: every package.json export target resolves on disk (runtime/cli/testing)
  + the .d.mts declaration sibling for the typed libraries (runtime/testing, not cli).
- reconciler: keyed insert-between [a,c]→[a,b,c]; replace a colored <Text> child with a
  plain string; setElementText A→B + the text-context guard; marginLeft removal reset.
- measure: empty <Text> contributes height 0 in a column; non-zero left (marginLeft=5 →
  5,1); measureTextNatural trailing/only-newline heights.
- flex: alignSelf='auto' == default + alignSelf removal resets to AUTO; the two
  space-around known-yoga-bug cases converted from test.skip to test.fails (they assert
  the DESIRED output and flip to a real failure if yoga ever fixes the bug); the documented
  flexDirection/flexWrap removal-reset divergence (was comment-only) now has a visual lock.
- overflow: out-of-bounds writes produce Ink's exact clipped frame (sparse past-width cell,
  filtered hole) — tightened from toBeDefined().
- components: inline + top-level non-empty fragment in <Text>; the previously-skipped
  ST-terminated OSC-8 hyperlink hard-wrap now passes ('abcde\nfghij') — un-skipped as a lock.

Codex-reviewed GENUINE.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-06-01 03:28:35 +08:00
committed by GitHub
parent 7d6a92ea15
commit 48558c5af6
10 changed files with 457 additions and 12 deletions
@@ -0,0 +1,92 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, test } from "vite-plus/test";
// Mirror of Ink's test/build-output.ts: walk each published package's
// package.json `exports` map and assert every string target actually exists in
// the built `dist/`. This is an INTEGRATION test run after `vp run build`
// (`vp run ci` builds first); a missing target is a real packaging gap that
// would ship a broken `import`/`require`.
const here = path.dirname(fileURLToPath(import.meta.url));
// integration/ -> runtime-tests/ -> packages/
const packagesDir = path.resolve(here, "..", "..");
type Exports = string | { [condition: string]: Exports };
/**
* Collect every string leaf reachable from an `exports` value, descending
* through nested condition objects (`import`/`require`/`types`/...) and named
* subpaths (`.`, `./internal`, `./package.json`). Each leaf is the literal path
* the resolver would hand back, so each must exist on disk.
*/
function collectTargets(value: Exports, out: string[] = []): string[] {
if (typeof value === "string") {
out.push(value);
return out;
}
for (const nested of Object.values(value)) {
collectTargets(nested, out);
}
return out;
}
/**
* For a TYPED library entry, the declaration sibling sits next to the runtime
* `.mjs` with a `.d.mts` extension (tsdown/`vp pack` emits `index.mjs` +
* `index.d.mts`). We assert it explicitly for runtime/testing — cli has no
* public type surface (no `index.d.mts`), so it is excluded.
*/
function declarationSibling(mjsTarget: string): string {
return mjsTarget.replace(/\.mjs$/, ".d.mts");
}
type PackageCase = {
/** Directory name under packages/ */
dir: string;
/** Whether this package ships a public type surface (.d.mts siblings). */
typed: boolean;
};
const cases: PackageCase[] = [
{ dir: "runtime", typed: true },
{ dir: "cli", typed: false },
{ dir: "testing", typed: true },
];
describe("build output: package.json exports resolve to built files", () => {
for (const { dir, typed } of cases) {
const pkgDir = path.join(packagesDir, dir);
const pkgJsonPath = path.join(pkgDir, "package.json");
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8")) as {
name: string;
exports: Exports;
};
test(`${pkg.name}: every exports target exists`, () => {
const targets = collectTargets(pkg.exports);
// Sanity: the map must have at least the root entry.
expect(targets.length).toBeGreaterThan(0);
for (const target of targets) {
const abs = path.join(pkgDir, target);
expect(fs.existsSync(abs), `${pkg.name} exports target missing: ${target}`).toBe(true);
}
});
if (typed) {
test(`${pkg.name}: each .mjs library export has a .d.mts declaration sibling`, () => {
const mjsTargets = collectTargets(pkg.exports).filter(
(t) => t.endsWith(".mjs") && t.startsWith("./dist/"),
);
// A typed library must expose at least one runtime entry.
expect(mjsTargets.length).toBeGreaterThan(0);
for (const mjs of mjsTargets) {
const dts = declarationSibling(mjs);
const abs = path.join(pkgDir, dts);
expect(fs.existsSync(abs), `${pkg.name} missing declaration sibling: ${dts}`).toBe(true);
}
});
}
}
});
@@ -62,6 +62,36 @@ test("keyed v-for reorder renders in new order", async () => {
expect(lines[2]).toContain("item-2");
});
// Ink reconciler.tsx:154-212 ("insert child between other children"): with
// STABLE keys, inserting a new child between two existing ones must place it in
// the right slot — not append, not reorder. Mirrors Ink's keyed [a,c] -> [a,b,c].
test("keyed insert between existing children places B between A and C", async () => {
const insert = shallowRef(false);
const App = defineComponent(
() => () =>
insert.value ? (
<Box flexDirection="column">
<Text key="a">A</Text>
<Text key="b">B</Text>
<Text key="c">C</Text>
</Box>
) : (
<Box flexDirection="column">
<Text key="a">A</Text>
<Text key="c">C</Text>
</Box>
),
);
const { lastFrame } = await render(App, { columns: 10 });
expect(lastFrame()).toBe("A\nC");
insert.value = true;
await nextTick();
// B lands in the middle (stable keys), so the column order is A / B / C.
expect(lastFrame()).toBe("A\nB\nC");
});
test("repeated list shuffles don't crash", async () => {
const items = shallowRef([1, 2, 3, 4, 5]);
const App = defineComponent(() => {
@@ -186,6 +186,66 @@ test("remeasure text when text nodes are changed", async () => {
expect(lastFrame()).toBe("abcx");
});
// Ink reconciler.tsx:328-344 / components.tsx:715-731 ("replace child node with
// text"): an outer <Text> whose only child is a colored <Text> is replaced across
// a rerender by a plain string. The frame must flip from the colored "test" to a
// bare "x" — the nested styled child node is fully torn down and the text-leaf
// takes its place.
test("replace a colored <Text> child with a plain string across a rerender", async () => {
const replace = shallowRef(false);
const { lastFrame } = await render(
defineComponent(() => () => (
<Text>{replace.value ? "x" : <Text color="green">test</Text>}</Text>
)),
{ columns: 100 },
);
// Before: the nested green child is the only content → chalk.green("test").
expect(lastFrame()).toBe(chalk.green("test"));
replace.value = true;
await nextTick();
// After: the styled child is gone, replaced by a plain text-leaf → "x".
expect(lastFrame()).toBe("x");
});
// Locks the node-ops setElementText host op + remeasure: flipping <Text>A</Text>
// to <Text>B</Text> goes through Vue's setElementText fast path (a single static
// text child), which clears the leaf, inserts the new one, and dirties the text
// measure owner so yoga remeasures. The frame must update A -> B.
test("setElementText path updates A to B and remeasures", async () => {
const flip = shallowRef(false);
const { lastFrame } = await render(
defineComponent(() => () => <Text>{flip.value ? "B" : "A"}</Text>),
{ columns: 100 },
);
expect(lastFrame()).toBe("A");
flip.value = true;
await nextTick();
expect(lastFrame()).toBe("B");
});
// The text-context guard fires only for NON-EMPTY raw text directly inside a
// <Box>. Vue materializes the empty branch of `cond ? 'oops' : ''` as an empty
// text-leaf (a fragment anchor), which node-ops insert() deliberately skips — so
// the empty case renders "" without throwing, while the non-empty "oops" throws.
test("<Box>{cond ? 'oops' : ''}</Box> throws for the non-empty branch only", async () => {
// `cond` drives the ternary at runtime (a literal true/false here is flagged as
// a constant condition by the linter; shallowRef keeps the exact two-branch shape).
const oopsCond = shallowRef(true);
const oops = defineComponent(() => () => <Box>{oopsCond.value ? "oops" : ""}</Box>);
await expect(render(oops)).rejects.toThrow(
/^Text string "oops" must be rendered inside <Text> component$/,
);
// The empty branch is a skipped fragment anchor — no text reaches the Box, so
// it renders an empty frame without tripping the guard.
const emptyCond = shallowRef(false);
const empty = defineComponent(() => () => <Box>{emptyCond.value ? "oops" : ""}</Box>);
const { lastFrame } = await render(empty, { columns: 100 });
expect(lastFrame()).toBe("");
});
test("text with content 'constructor' wraps correctly", async () => {
const { lastFrame } = await render(
defineComponent(() => () => <Text>constructor</Text>),
@@ -617,6 +677,35 @@ test("number", async () => {
expect(lastFrame()).toBe("1");
});
// Ink components.tsx:80-88,363-372: a fragment nested inline inside <Text> is
// flattened into the surrounding text run, so "Hello " + <>World</> squashes to
// "Hello World" (the fragment contributes no layout of its own).
test("inline fragment inside <Text> flattens into the text run", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Text>
Hello <>World</>
</Text>
)),
{ columns: 100 },
);
expect(lastFrame()).toBe("Hello World");
});
// A top-level fragment wrapping a single <Text> renders as that text — the
// fragment is transparent at the root, matching Ink's root-fragment handling.
test("top-level fragment wrapping a <Text> renders the text", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<>
<Text>Hello World</Text>
</>
)),
{ columns: 100 },
);
expect(lastFrame()).toBe("Hello World");
});
test("do not wrap text with BEL-terminated OSC hyperlinks", async () => {
const hyperlink = "\x1b]8;;https://example.com\x07Click here\x1b]8;;\x07";
const output = renderToString(
@@ -739,8 +828,11 @@ test("hard-wrap text containing an inline erase-line (\\x1b[2K) sequence across
expect(output).toBe(`${line1}\n${line2}`);
});
// Feature gap: ST-terminated OSC sequences not handled correctly in wrap-ansi path
test.skip("hard-wrap single-word ST-terminated OSC hyperlink", async () => {
// ST-terminated (ESC\) OSC-8 hyperlink, single long word, hard-wrapped at width 5.
// The wrap protection covers both OSC terminators (BEL and ST), so the word breaks
// at the cell boundary exactly like its BEL-terminated sibling above: "abcde\nfghij".
// Verified against Ink v7.0.4 (un-skipped — vue produces Ink's identical output).
test("hard-wrap single-word ST-terminated OSC hyperlink", async () => {
const hyperlink = "\x1b]8;;https://example.com\x1b\\abcdefghij\x1b]8;;\x1b\\";
const output = renderToString(
defineComponent(() => () => (
@@ -47,6 +47,33 @@ describe("useBoxMetrics", () => {
expect(pos.value.t).toBe(0);
});
// Ink use-box-metrics.tsx:37-61 ("returns correct position"): a tracked box on
// the SECOND row of a column with marginLeft=5 must report left=5 (the margin)
// and top=1 (the row below the first line).
test("returns non-zero left/top for an offset box on the second row", async () => {
const pos = shallowRef({ l: -1, t: -1 });
const App = defineComponent(() => {
const boxRef = ref(null);
const metrics = useBoxMetrics(boxRef);
watchEffect(() => {
pos.value = { l: metrics.left.value, t: metrics.top.value };
});
return () => (
<Box flexDirection="column">
<Text>first line</Text>
<Box ref={boxRef} marginLeft={5}>
<Text>tracked</Text>
</Box>
</Box>
);
});
await render(App, { columns: 100 });
await nextTick();
// marginLeft=5 → left=5; second row → top=1.
expect(pos.value.l).toBe(5);
expect(pos.value.t).toBe(1);
});
test("hasMeasured starts false", async () => {
let measured = false;
const App = defineComponent(() => {
@@ -1,4 +1,4 @@
import { defineComponent } from "vue";
import { defineComponent, nextTick, shallowRef } from "vue";
import { expect, test } from "vite-plus/test";
import { render } from "@vue-tui/testing";
import { Box, Text, Newline } from "@vue-tui/runtime";
@@ -117,6 +117,60 @@ test("row - align self stretch", async () => {
expect(lastFrame({ trimLines: true })).toBe("┌─┐\n│X│\n│ │\n│ │\n└─┘");
});
// Ink styles.ts:580 maps alignSelf="auto" to yoga's ALIGN_AUTO, which is the
// default — so an explicit alignSelf="auto" must render identically to no
// alignSelf at all (the child stays at the cross-axis start, left-aligned here).
test("column - alignSelf='auto' equals the no-alignSelf default", async () => {
const { lastFrame: withAuto } = await render(
defineComponent(() => () => (
<Box flexDirection="column" width={10}>
<Box alignSelf="auto">
<Text>Test</Text>
</Box>
</Box>
)),
{ columns: 100 },
);
const { lastFrame: withoutAlign } = await render(
defineComponent(() => () => (
<Box flexDirection="column" width={10}>
<Box>
<Text>Test</Text>
</Box>
</Box>
)),
{ columns: 100 },
);
// Both stay at the start (left) — "auto" is the default, not center/end.
expect(withAuto({ trimLines: true })).toBe("Test");
expect(withAuto({ trimLines: true })).toBe(withoutAlign({ trimLines: true }));
});
// G19: removing alignSelf must reset to yoga's ALIGN_AUTO default (per the
// declarative contract render = f(current props)). The child starts flex-end
// (right-aligned in a width-10 column) and reverts to the unaligned default
// (left) once alignSelf is removed.
test("column - alignSelf removal resets to AUTO default (G19)", async () => {
const aligned = shallowRef(true);
const { lastFrame } = await render(
defineComponent(() => () => (
<Box flexDirection="column" width={10}>
<Box {...(aligned.value ? { alignSelf: "flex-end" } : {})}>
<Text>Test</Text>
</Box>
</Box>
)),
{ columns: 100 },
);
// flex-end pushes "Test" to the right edge of the width-10 column.
expect(lastFrame({ trimLines: true })).toBe(" Test");
aligned.value = false;
await nextTick();
// After removal, alignSelf resets to AUTO → back to the left (unaligned default).
expect(lastFrame({ trimLines: true })).toBe("Test");
});
test("row - align self baseline", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
@@ -80,9 +80,13 @@ test("row - space evenly two text nodes", async () => {
expect(lastFrame({ trimLines: true })).toBe(" A B");
});
// Yoga has a bug, where first child in a container with space-around doesn't have
// the correct X coordinate and measure function is used on that child node
test.skip("row - align two text nodes with equal space around them — known Yoga issue", async () => {
// Yoga has a bug where the first child in a space-around container gets the wrong
// X coordinate (its measure func runs on a mis-placed node). Mirrors Ink's
// test.failing for the same input: we assert the DESIRED output " A B" and mark
// the test `fails`, so it RUNS and passes only WHILE the shared yoga bug persists.
// If yoga ever fixes this, the test flips to passing-unexpectedly and fails the
// run — flagging that this comment (and Ink's matching divergence note) is stale.
test.fails("row - align two text nodes with equal space around them — known Yoga issue", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box flexDirection="row" justifyContent="space-around" width={5}>
@@ -144,9 +148,10 @@ test("column - align two text nodes on the edges", async () => {
expect(lastFrame({ trimLines: true })).toBe("A\n\n\nB");
});
// Yoga has a bug, where first child in a container with space-around doesn't have
// the correct X coordinate and measure function is used on that child node
test.skip("column - align two text nodes with equal space around them — known Yoga issue", async () => {
// Same yoga space-around first-child bug on the column axis. test.fails asserts
// the DESIRED "\nA\n\nB\n" and passes only while the bug persists (see the row
// case above for the rationale). Mirrors Ink's test.failing.
test.fails("column - align two text nodes with equal space around them — known Yoga issue", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box flexDirection="column" justifyContent="space-around" height={5}>
@@ -575,13 +575,52 @@ test("nested overflow", async () => {
expect(lastFrame({ trimLines: true })).toBe("AA\nBB\nXXXX\nYYYY\n");
});
test("out of bounds writes do not crash", async () => {
// Just verify it renders without throwing; exact output varies by terminal width
/** Build a round-border box of the given inner width / total height, like
* boxen('', { width, height, borderStyle: 'round' }). All lines are exactly
* `width` columns wide (the border glyphs included). */
function roundBox(width: number, height: number): string {
const inner = width - 2;
const top = `╭${"─".repeat(inner)}╮`;
const bottom = `╰${"─".repeat(inner)}╯`;
const middle = `│${" ".repeat(inner)}│`;
const lines = [top];
for (let i = 0; i < height - 2; i++) lines.push(middle);
lines.push(bottom);
return lines.join("\n");
}
// Ink overflow.tsx:509-528 ("out of bounds writes do not crash"): a width-12,
// height-10 round-border box rendered into a 10-column terminal. The box is 2
// columns WIDER than the terminal, so the renderer clips. Ink keeps the past-width
// right-border glyph as a sparse cell while the over-width interior column (col 10)
// is dropped — so each middle row collapses from "│"+10 spaces+"│" to "│"+9
// spaces+"│" (the col-10 hole filtered out, the right "│" surviving at col 11). The
// top/bottom border rows keep their full 12-column run. Mirrors Ink's exact frame
// (it slices middle lines to `line.slice(0,10) + line[11]`).
test("out of bounds writes do not crash (exact clipped frame)", async () => {
const { lastFrame } = await render(
defineComponent(() => () => <Box width={12} height={10} borderStyle="round" />),
{ columns: 10 },
);
expect(lastFrame({ trimLines: true })).toBeDefined();
const expected = roundBox(12, 10)
.split("\n")
.map((line, index) =>
index === 0 || index === 9
? line // top/bottom borders survive at full 12-column width
: `${line.slice(0, 10)}${line[11] ?? ""}`,
)
.join("\n");
// Sanity-check the constructed expectation matches the documented shape:
// full borders top/bottom, "│" + 9 spaces + "│" (11 cols) middle rows.
const expectedLines = expected.split("\n");
expect(expectedLines[0]).toBe(`╭${"─".repeat(10)}╮`);
expect(expectedLines[9]).toBe(`╰${"─".repeat(10)}╯`);
expect(expectedLines[1]).toBe(`│${" ".repeat(9)}│`);
// lastFrame() (no trim) is byte-exact — assert the full clipped frame.
expect(lastFrame()).toBe(expected);
});
// --- absolute overlay wide glyph clipping (issue #10) ---
@@ -27,6 +27,26 @@ test("reset prop when it's removed from the element", async () => {
expect(lastFrame()).toBe("x");
});
// Ink reconciler.tsx:76-96 ("remove style prop from intrinsic node"): removing
// marginLeft must reset the yoga margin edge to 0, not keep the stale 1. The
// frame flips from " X" (1-col left margin) to "X".
test("reset marginLeft to 0 on removal (Ink reconciler parity)", async () => {
const withStyle = shallowRef(true);
const Dynamic = defineComponent(() => () => (
<Box {...(withStyle.value ? { marginLeft: 1 } : {})}>
<Text>X</Text>
</Box>
));
const { lastFrame } = await render(Dynamic, { columns: 100 });
expect(lastFrame()).toBe(" X");
withStyle.value = false;
await nextTick();
expect(lastFrame()).toBe("X");
});
// G19: dynamic removal of yoga style props must reset to yoga/Ink default, not keep stale value.
test("reset marginTop to 0 on removal (G19)", async () => {
@@ -350,3 +370,57 @@ test("reset position to relative on removal (G19)", async () => {
// After reset to relative (position prop removed), A re-enters flow above B
expect(lastFrame({ trimLines: true })).toBe("A\nB\n");
});
// G19 (the documented flexDirection/flexWrap divergence — previously only a code
// comment, no test): removing flexDirection / flexWrap must reset to the yoga
// default (row / nowrap), per the declarative contract render = f(current props).
// Ink's applyStyles persists the last value on a removed prop; vue-tui resets to
// the default. These lock the VISUAL effect of the reset.
test("reset flexDirection to row (default) on removal (G19 divergence)", async () => {
// While flexDirection="column" the two texts stack (A over B); after removal the
// box reverts to the row default and they sit side-by-side (AB).
const col = shallowRef(true);
const Dynamic = defineComponent(() => () => (
<Box {...(col.value ? { flexDirection: "column" } : {})}>
<Text>A</Text>
<Text>B</Text>
</Box>
));
const { lastFrame } = await render(Dynamic, { columns: 100 });
expect(lastFrame({ trimLines: true })).toBe("A\nB");
col.value = false;
await nextTick();
expect(lastFrame({ trimLines: true })).toBe("AB");
});
test("reset flexWrap to nowrap (default) on removal (G19 divergence)", async () => {
// Width-4 container with three width-2 boxes. While flexWrap="wrap" they wrap to
// two rows (AA BB on row 0, CC on row 1); after removal the box reverts to the
// nowrap default, overflowing all three onto a single row (AABBCC).
const wrap = shallowRef(true);
const Dynamic = defineComponent(() => () => (
<Box width={4} {...(wrap.value ? { flexWrap: "wrap" } : {})}>
<Box width={2} flexShrink={0}>
<Text>AA</Text>
</Box>
<Box width={2} flexShrink={0}>
<Text>BB</Text>
</Box>
<Box width={2} flexShrink={0}>
<Text>CC</Text>
</Box>
</Box>
));
const { lastFrame } = await render(Dynamic, { columns: 100 });
expect(lastFrame({ trimLines: true })).toBe("AABB\nCC");
wrap.value = false;
await nextTick();
expect(lastFrame({ trimLines: true })).toBe("AABBCC");
});
@@ -37,3 +37,21 @@ test("narrow truncate matches Ink's re-measure-then-truncate behavior", async ()
expect(lines[0]).toBe("x");
expect(lines[1]).toBe("…");
});
// Ink measure-text.tsx returns height 0 for empty text (text.length === 0), and
// the yoga measure func short-circuits raw === "" to {width:0,height:0}. So an
// empty <Text> in a column contributes NO row — the only visible line is the
// non-empty sibling, with NO leading blank line above it.
test("empty <Text> in a column contributes height 0 (no leading blank line)", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box flexDirection="column">
<Text>{""}</Text>
<Text>hello</Text>
</Box>
)),
{ columns: 100 },
);
// Single line "hello" — the empty text adds no row above it.
expect(lastFrame()).toBe("hello");
});
@@ -412,6 +412,20 @@ test("measureTextNatural uses widest line and raw line count", () => {
expect(measureTextNatural("")).toEqual({ width: 0, height: 1 });
});
// Ink measure-text.tsx:24-34 (widest-line width, `text.split('\n').length`
// height): a TRAILING newline produces an extra (empty) trailing line, and a
// string of only newlines is all empty lines. height = number of \n-separated
// segments; width = widest line.
test("measureTextNatural counts the trailing-newline empty line", () => {
// "hello\n" → ["hello", ""] → widest line 5, two lines.
expect(measureTextNatural("hello\n")).toEqual({ width: 5, height: 2 });
});
test("measureTextNatural counts an only-newline string as all empty lines", () => {
// "\n\n" → ["", "", ""] → no visible width, three lines.
expect(measureTextNatural("\n\n")).toEqual({ width: 0, height: 3 });
});
test("clipped empty write does not corrupt existing wide characters", () => {
// When a write is clipped to an empty string, the boundary cleanup
// must not run, otherwise it would destroy a wide character that