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:
@@ -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}>
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user