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
@@ -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(() => () => (