test(runtime): lock exact-byte output (cursor-helpers, render-to-string, text), matching Ink (#112)

Round-2 test-only locks tightening lax `.toContain()` to exact equality, mirroring
Ink's `t.is` assertions (all behaviors already at parity):
- cursor-helpers: buildCursorSuffix/buildReturnToBottom/buildReturnToBottomPrefix/
  buildCursorOnlySequence exact full output + show/hide cursor constant literals.
- render-to-string: column "Line 1\nLine 2", paddingLeft "  Padded", a byte-exact
  single-border 20-wide frame, and byte-exact gap wrap ("A B\n\nC") + column ("A\n\nB")
  (the trimLines live tests can't catch trailing-space regressions).
- text: OSC-8 link exact bytes; a new RIS/ESC-c strip test (the existing test only
  covered the ESC#8 leg).
- box-in-text validation: the exact "Text string \"…\" must be rendered inside <Text>
  component" message via anchored regex (catches extra prefix/suffix, not just substring).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-06-01 02:20:06 +08:00
committed by GitHub
parent 2838d0218e
commit d5c3f39871
4 changed files with 97 additions and 42 deletions
@@ -20,10 +20,18 @@ test("fail when text nodes are not within <Text> component (mixed)", async () =>
<Text>World</Text>
</Box>
));
await expect(render(App)).rejects.toThrow("must be rendered inside <Text>");
// Lock the EXACT message including the quoted offending string. Ink components.tsx.
// Anchored regex (not a substring) so extra prefix/suffix text would fail too.
await expect(render(App)).rejects.toThrow(
/^Text string "Hello" must be rendered inside <Text> component$/,
);
});
test("fail when text node is not within <Text> component (full)", async () => {
const App = defineComponent(() => () => <Box>Hello World</Box>);
await expect(render(App)).rejects.toThrow("must be rendered inside <Text>");
// Lock the EXACT message: the whole offending string is quoted. Ink components.tsx.
// Anchored regex (not a substring) so extra prefix/suffix text would fail too.
await expect(render(App)).rejects.toThrow(
/^Text string "Hello World" must be rendered inside <Text> component$/,
);
});
@@ -505,6 +505,17 @@ test("strip complete ESC#8 (DECALN) sequence without clipping at a tight width",
expect(stripAnsi(output)).toBe("ABC");
});
// Mirrors Ink text.tsx:277-283 ("strip complete ESC control sequences with
// intermediates"). The existing ESC#8-only test above misses the ESC-c (RIS, full
// terminal reset) leg: sanitizeAnsi must strip BOTH the intermediate-byte ESC#8 and
// the bare ESC c so neither leaks into the painted frame, leaving the visible "ABC".
test("strip complete ESC control sequences with intermediates (ESC#8 and ESC c / RIS)", async () => {
const frame = await renderText(`A${ESC}#8B${ESC}cC`);
expect(frame).not.toContain(`${ESC}#8`);
expect(frame).not.toContain(`${ESC}c`);
expect(stripAnsi(frame)).toBe("ABC");
});
test("strip tmux DCS passthrough wrappers with ST-terminated OSC payload", async () => {
const wrappedStart = `${ESC}Ptmux;${ESC}${ESC}]8;;https://example.com${ESC}${ESC}\\${ESC}\\`;
const wrappedEnd = `${ESC}Ptmux;${ESC}${ESC}]8;;${ESC}${ESC}\\${ESC}\\`;
@@ -755,6 +766,8 @@ test("link ansi escapes are closed properly", async () => {
defineComponent(() => () => <Text>{ansiEscapes.link("Example", "https://example.com")}</Text>),
{ columns: 100 },
);
expect(output).toContain("Example");
expect(output).toContain("example.com");
// Lock the EXACT bytes: the OSC-8 hyperlink must round-trip unchanged (open + label +
// close). Ink components.tsx: t.is(output, ']8;;https://example.comExample]8;;') —
// identical to ansiEscapes.link(...) byte-for-byte.
expect(output).toBe(ansiEscapes.link("Example", "https://example.com"));
});
@@ -52,8 +52,8 @@ describe("renderToString", () => {
</Box>
));
const output = renderToString(App, { columns: 20 });
expect(output).toContain("Line 1");
expect(output).toContain("Line 2");
// Lock the EXACT bytes (Ink render-to-string.tsx: t.is(output, 'Line 1\nLine 2')).
expect(output).toBe("Line 1\nLine 2");
});
test("useInput does not throw in renderToString", () => {
@@ -141,11 +141,12 @@ describe("renderToString", () => {
test("renders padding correctly", () => {
const App = defineComponent(() => () => (
<Box paddingLeft={2}>
<Text>padded</Text>
<Text>Padded</Text>
</Box>
));
const output = renderToString(App, { columns: 20 });
expect(output).toContain(" padded");
// Lock the EXACT bytes (Ink render-to-string.tsx: t.is(output, ' Padded')).
expect(output).toBe(" Padded");
});
test("rethrows non-Error values as wrapped Error", () => {
@@ -216,10 +217,12 @@ describe("renderToString", () => {
</Box>
));
const output = renderToString(App, { columns: 20 });
// Border characters should be present (single border uses box-drawing chars)
expect(output).toContain("Bordered");
expect(output).toContain("│");
expect(output).toContain("─");
// Lock the EXACT boxen frame: a 20-wide single border (top corner + 18 ─ + corner,
// content row "Bordered" + 10 fill spaces, bottom border). Byte-identical to Ink's
// boxen('Bordered', { width: 20, borderStyle: 'single' }) (render-to-string.tsx).
expect(output).toBe(
"┌──────────────────┐\n" + "│Bordered │\n" + "└──────────────────┘",
);
});
test("renders box with flex direction row", () => {
@@ -245,6 +248,33 @@ describe("renderToString", () => {
expect(output).toBe("A B");
});
// Byte-exact gap variants (Ink gap.tsx). The live render() gap tests use
// trimLines:true, which masks trailing-space regressions; the renderToString path
// is byte-exact, so these lock the WRAP and COLUMN gaps without that mask.
test("renders gap with flexWrap (wraps to a new row separated by a row gap)", () => {
const App = defineComponent(() => () => (
<Box gap={1} width={3} flexWrap="wrap">
<Text>A</Text>
<Text>B</Text>
<Text>C</Text>
</Box>
));
// Ink: t.is(output, 'A B\n\nC') — "A B" fills width 3, "C" wraps below, the
// blank line is the row gap between the two wrapped rows.
expect(renderToString(App)).toBe("A B\n\nC");
});
test("renders column gap (blank line between stacked items)", () => {
const App = defineComponent(() => () => (
<Box flexDirection="column" gap={1}>
<Text>A</Text>
<Text>B</Text>
</Box>
));
// Ink: t.is(output, 'A\n\nB')
expect(renderToString(App)).toBe("A\n\nB");
});
test("renders spacer pushing content apart", () => {
const App = defineComponent(() => () => (
<Box width={20}>
+34 -30
View File
@@ -1,3 +1,4 @@
import ansiEscapes from "ansi-escapes";
import { describe, test, expect } from "vite-plus/test";
import {
cursorPositionChanged,
@@ -31,30 +32,33 @@ describe("cursor-helpers", () => {
expect(cursorPositionChanged({ x: 0, y: 0 }, undefined)).toBe(true);
});
// The escape constants must match their hardcoded literals exactly (Ink locks
// these byte-for-byte in cursor-helpers.tsx).
test("escape constants are the exact DECTCEM byte sequences", () => {
expect(showCursorEscape).toBe("\x1b[?25h");
expect(hideCursorEscape).toBe("\x1b[?25l");
});
test("buildCursorSuffix returns empty for undefined position", () => {
expect(buildCursorSuffix(5, undefined)).toBe("");
});
test("buildCursorSuffix moves cursor up and to position", () => {
const result = buildCursorSuffix(5, { x: 3, y: 2 });
// Should move up 3 lines (5 - 2), move to column 3, and show cursor
expect(result).toContain("\x1b[3A"); // cursorUp(3)
expect(result).toContain("\x1b[4G"); // cursorTo(3) — 1-indexed column
expect(result).toContain("\x1b[?25h"); // show cursor
// moveUp = 3 - 1 = 2 (Ink's exact case). Lock the FULL output, not substrings.
const result = buildCursorSuffix(3, { x: 5, y: 1 });
expect(result).toBe(ansiEscapes.cursorUp(2) + ansiEscapes.cursorTo(5) + showCursorEscape);
});
test("buildCursorSuffix omits cursorUp when already on target line", () => {
// moveUp = 3 - 3 = 0, so no cursorUp — exact composition is cursorTo + show.
const result = buildCursorSuffix(3, { x: 0, y: 3 });
expect(result).not.toContain("A"); // no cursorUp
expect(result).toContain("\x1b[?25h"); // still shows cursor
expect(result).toBe(ansiEscapes.cursorTo(0) + showCursorEscape);
});
test("buildCursorSuffix - cursor at first line of single-line output", () => {
const result = buildCursorSuffix(1, { x: 4, y: 0 });
// moveUp = 1 - 0 = 1
expect(result).toContain("\x1b[1A"); // cursorUp(1)
expect(result).toContain("\x1b[5G"); // cursorTo(4) — 1-indexed column
expect(result).toContain("\x1b[?25h"); // show cursor
const result = buildCursorSuffix(1, { x: 4, y: 0 });
expect(result).toBe(ansiEscapes.cursorUp(1) + ansiEscapes.cursorTo(4) + showCursorEscape);
});
test("buildReturnToBottom returns empty for undefined position", () => {
@@ -62,17 +66,15 @@ describe("cursor-helpers", () => {
});
test("buildReturnToBottom moves cursor down and to column 0", () => {
const result = buildReturnToBottom(5, { x: 3, y: 2 });
// down = 5 - 1 - 2 = 2
expect(result).toContain("\x1b[2B"); // cursorDown(2)
expect(result).toContain("\x1b[1G"); // cursorTo(0) — 1-indexed column 1
// down = 4 - 1 - 0 = 3 (Ink's exact case). Lock the FULL output.
const result = buildReturnToBottom(4, { x: 5, y: 0 });
expect(result).toBe(ansiEscapes.cursorDown(3) + ansiEscapes.cursorTo(0));
});
test("buildReturnToBottom - no cursorDown when cursor already at bottom", () => {
// down = 4 - 1 - 3 = 0, so no cursorDown — exact composition is just cursorTo.
const result = buildReturnToBottom(4, { x: 0, y: 3 });
// down = 4 - 1 - 3 = 0, so no cursorDown
expect(result).not.toContain("B"); // no cursorDown
expect(result).toContain("\x1b[1G"); // cursorTo(0)
expect(result).toBe(ansiEscapes.cursorTo(0));
});
test("buildReturnToBottomPrefix returns empty when cursor was not shown", () => {
@@ -80,9 +82,9 @@ describe("cursor-helpers", () => {
});
test("buildReturnToBottomPrefix hides cursor and returns to bottom", () => {
const result = buildReturnToBottomPrefix(true, 5, { x: 0, y: 2 });
expect(result).toContain(hideCursorEscape);
expect(result).toContain("\x1b[2B"); // cursorDown(2): 5-1-2
// Lock the FULL output: hide + buildReturnToBottom (Ink's exact composition).
const result = buildReturnToBottomPrefix(true, 4, { x: 0, y: 0 });
expect(result).toBe(hideCursorEscape + buildReturnToBottom(4, { x: 0, y: 0 }));
});
test("buildReturnToBottomPrefix - with undefined previousCursorPosition still hides cursor", () => {
@@ -91,18 +93,20 @@ describe("cursor-helpers", () => {
});
test("buildCursorOnlySequence combines hide + return + reposition", () => {
// Lock the FULL composition: hide prefix + buildReturnToBottom + buildCursorSuffix
// (Ink's exact case, cursor-helpers.tsx).
const result = buildCursorOnlySequence({
cursorWasShown: true,
previousLineCount: 5,
previousCursorPosition: { x: 0, y: 2 },
visibleLineCount: 5,
cursorPosition: { x: 3, y: 1 },
previousLineCount: 2,
previousCursorPosition: { x: 0, y: 0 },
visibleLineCount: 1,
cursorPosition: { x: 3, y: 0 },
});
expect(result).toContain(hideCursorEscape);
expect(result).toContain(showCursorEscape);
// Should contain return-to-bottom (down 2) and cursor suffix (up 4)
expect(result).toContain("\x1b[2B"); // cursorDown(2)
expect(result).toContain("\x1b[4A"); // cursorUp(4): 5-1
const expected =
hideCursorEscape +
buildReturnToBottom(2, { x: 0, y: 0 }) +
buildCursorSuffix(1, { x: 3, y: 0 });
expect(result).toBe(expected);
});
test("buildCursorOnlySequence skips hide when cursor was not shown", () => {