diff --git a/packages/runtime-tests/integration/build-output.test.ts b/packages/runtime-tests/integration/build-output.test.ts
new file mode 100644
index 0000000..67a8205
--- /dev/null
+++ b/packages/runtime-tests/integration/build-output.test.ts
@@ -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);
+ }
+ });
+ }
+ }
+});
diff --git a/packages/runtime-tests/integration/components/conditional-list.test.tsx b/packages/runtime-tests/integration/components/conditional-list.test.tsx
index d6f01f5..86e03e1 100644
--- a/packages/runtime-tests/integration/components/conditional-list.test.tsx
+++ b/packages/runtime-tests/integration/components/conditional-list.test.tsx
@@ -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 ? (
+
+ A
+ B
+ C
+
+ ) : (
+
+ A
+ C
+
+ ),
+ );
+
+ 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(() => {
diff --git a/packages/runtime-tests/integration/components/text.test.tsx b/packages/runtime-tests/integration/components/text.test.tsx
index 27f322d..b4654e0 100644
--- a/packages/runtime-tests/integration/components/text.test.tsx
+++ b/packages/runtime-tests/integration/components/text.test.tsx
@@ -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 whose only child is a colored 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 child with a plain string across a rerender", async () => {
+ const replace = shallowRef(false);
+ const { lastFrame } = await render(
+ defineComponent(() => () => (
+ {replace.value ? "x" : test}
+ )),
+ { 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 A
+// to B 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(() => () => {flip.value ? "B" : "A"}),
+ { 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
+// . 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("{cond ? 'oops' : ''} 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(() => () => {oopsCond.value ? "oops" : ""});
+ await expect(render(oops)).rejects.toThrow(
+ /^Text string "oops" must be rendered inside 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(() => () => {emptyCond.value ? "oops" : ""});
+ const { lastFrame } = await render(empty, { columns: 100 });
+ expect(lastFrame()).toBe("");
+});
+
test("text with content 'constructor' wraps correctly", async () => {
const { lastFrame } = await render(
defineComponent(() => () => constructor),
@@ -617,6 +677,35 @@ test("number", async () => {
expect(lastFrame()).toBe("1");
});
+// Ink components.tsx:80-88,363-372: a fragment nested inline inside 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 flattens into the text run", async () => {
+ const { lastFrame } = await render(
+ defineComponent(() => () => (
+
+ Hello <>World>
+
+ )),
+ { columns: 100 },
+ );
+ expect(lastFrame()).toBe("Hello World");
+});
+
+// A top-level fragment wrapping a single renders as that text — the
+// fragment is transparent at the root, matching Ink's root-fragment handling.
+test("top-level fragment wrapping a renders the text", async () => {
+ const { lastFrame } = await render(
+ defineComponent(() => () => (
+ <>
+ Hello World
+ >
+ )),
+ { 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(() => () => (
diff --git a/packages/runtime-tests/integration/composables/use-box-metrics.test.tsx b/packages/runtime-tests/integration/composables/use-box-metrics.test.tsx
index b77416d..6494833 100644
--- a/packages/runtime-tests/integration/composables/use-box-metrics.test.tsx
+++ b/packages/runtime-tests/integration/composables/use-box-metrics.test.tsx
@@ -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 () => (
+
+ first line
+
+ tracked
+
+
+ );
+ });
+ 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(() => {
diff --git a/packages/runtime-tests/integration/layout/flex-align-self.test.tsx b/packages/runtime-tests/integration/layout/flex-align-self.test.tsx
index 1e0d802..e1b909c 100644
--- a/packages/runtime-tests/integration/layout/flex-align-self.test.tsx
+++ b/packages/runtime-tests/integration/layout/flex-align-self.test.tsx
@@ -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(() => () => (
+
+
+ Test
+
+
+ )),
+ { columns: 100 },
+ );
+ const { lastFrame: withoutAlign } = await render(
+ defineComponent(() => () => (
+
+
+ Test
+
+
+ )),
+ { 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(() => () => (
+
+
+ Test
+
+
+ )),
+ { 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(() => () => (
diff --git a/packages/runtime-tests/integration/layout/flex-justify-content.test.tsx b/packages/runtime-tests/integration/layout/flex-justify-content.test.tsx
index 2a9da34..5d2bd77 100644
--- a/packages/runtime-tests/integration/layout/flex-justify-content.test.tsx
+++ b/packages/runtime-tests/integration/layout/flex-justify-content.test.tsx
@@ -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(() => () => (
@@ -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(() => () => (
diff --git a/packages/runtime-tests/integration/layout/overflow.test.tsx b/packages/runtime-tests/integration/layout/overflow.test.tsx
index 73058a9..48f2072 100644
--- a/packages/runtime-tests/integration/layout/overflow.test.tsx
+++ b/packages/runtime-tests/integration/layout/overflow.test.tsx
@@ -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(() => () => ),
{ 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) ---
diff --git a/packages/runtime-tests/integration/layout/prop-reset.test.tsx b/packages/runtime-tests/integration/layout/prop-reset.test.tsx
index 541ba52..0721347 100644
--- a/packages/runtime-tests/integration/layout/prop-reset.test.tsx
+++ b/packages/runtime-tests/integration/layout/prop-reset.test.tsx
@@ -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(() => () => (
+
+ X
+
+ ));
+
+ 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(() => () => (
+
+ A
+ B
+
+ ));
+
+ 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(() => () => (
+
+
+ AA
+
+
+ BB
+
+
+ CC
+
+
+ ));
+
+ 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");
+});
diff --git a/packages/runtime-tests/integration/layout/text-measure-parity.test.tsx b/packages/runtime-tests/integration/layout/text-measure-parity.test.tsx
index abc65af..e694b4c 100644
--- a/packages/runtime-tests/integration/layout/text-measure-parity.test.tsx
+++ b/packages/runtime-tests/integration/layout/text-measure-parity.test.tsx
@@ -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 in a column contributes NO row — the only visible line is the
+// non-empty sibling, with NO leading blank line above it.
+test("empty in a column contributes height 0 (no leading blank line)", async () => {
+ const { lastFrame } = await render(
+ defineComponent(() => () => (
+
+ {""}
+ hello
+
+ )),
+ { columns: 100 },
+ );
+ // Single line "hello" — the empty text adds no row above it.
+ expect(lastFrame()).toBe("hello");
+});
diff --git a/packages/runtime/src/host/text-measure.test.ts b/packages/runtime/src/host/text-measure.test.ts
index 179655e..39c7c7b 100644
--- a/packages/runtime/src/host/text-measure.test.ts
+++ b/packages/runtime/src/host/text-measure.test.ts
@@ -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