test: backfill Ink-parity coverage gaps (suite as a superset of Ink) (#130)

Regression tests for behaviors the audit found correct-but-unpinned, so the
suite is a strict superset of Ink:
- A07: two <Static> regions both render (the additive divergence)
- B04: Static render-prop index = absolute index across appends; container
  vertical padding adds blank rows to the static frame
- B11: lazy raw-mode acquire/release under rawMode:'auto' (the path the
  'always' default masks)
- B19: child useCursor unmount emits the cursor-hide escape (stream-level)
- B20: animation interval 0/negative clamps to 1ms (normalizeInterval unit) and
  advances without busy-hang
- B21/B28: INK_SCREEN_READER env auto-detection + useIsScreenReaderEnabled
  true-path (env tests isolated in a *.sequential file per the global-state rule)
- B29: renderToString serves useCursor/usePaste/useTerminalSize/useAnimation/
  useBoxMetrics as inert no-ops (don't throw)
- B30: dedicated columnGap/rowGap props + their removal-reset

Test-only; no production changes. Codex-reviewed for non-vacuousness, Ink
correctness, and process-global isolation.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-06-03 03:42:33 +08:00
committed by GitHub
parent 5f55b59b70
commit c8c2c4a32d
9 changed files with 728 additions and 2 deletions
@@ -774,3 +774,129 @@ test("Static items do not add blank lines to the dynamic frame", async () => {
const frame = lastFrame()!;
expect(frame).toBe("[live]");
});
// A07 — multiple <Static> regions both render.
//
// Ink honors only the FIRST <Static> in the tree (ink.tsx tracks a single
// `staticNode`); a second one is silently dropped. vue-tui is an ADDITIVE
// DIVERGENCE: every <Static> node found by findStatics() is painted, so two
// independent static regions in one tree BOTH emit their items. This pins that
// superset behavior (there was previously no test mounting two regions).
test("two separate <Static> regions both render their items (additive divergence)", async () => {
const headerItems = shallowRef<string[]>([]);
const logItems = shallowRef<string[]>([]);
const App = defineComponent(() => () => (
<Box flexDirection="column">
<Static items={headerItems.value}>
{{ default: ({ item }: { item: string }) => <Text key={item}>{item}</Text> }}
</Static>
<Static items={logItems.value}>
{{ default: ({ item }: { item: string }) => <Text key={item}>{item}</Text> }}
</Static>
<Text>[live]</Text>
</Box>
));
const { frames } = await render(App);
// Populate both regions; each region's items must appear in the emitted output.
headerItems.value = ["HEADER-1", "HEADER-2"];
logItems.value = ["LOG-1", "LOG-2"];
await nextTick();
await new Promise((r) => setTimeout(r, 50));
await nextTick();
const allOutput = frames.join("");
// BOTH regions render (Ink would drop the second).
expect(allOutput).toContain("HEADER-1");
expect(allOutput).toContain("HEADER-2");
expect(allOutput).toContain("LOG-1");
expect(allOutput).toContain("LOG-2");
});
// B04(a) — the render-prop's SECOND arg (`index`) is the ABSOLUTE array index,
// stable across INCREMENTAL appends.
//
// Static.ts renders `items.slice(cursor)` and passes `index = cursor + i`, where
// the cursor advances to items.length after each batch is written. So an item's
// index is its position in the FULL list, never its position within the append
// batch. This mirrors Ink's Static, which renders `items.slice(index)` and passes
// the absolute index to the render prop. (Confirmed: batch [a0,a1] → indices 0,1;
// batch [b2,b3] appended → indices 2,3, NOT 0,1.)
test("Static render-prop index is the absolute array index across incremental appends", async () => {
const seen: Array<{ item: string; index: number }> = [];
const items = shallowRef<string[]>([]);
const App = defineComponent(() => () => (
<Box>
<Static items={items.value}>
{{
default: ({ item, index }: { item: string; index: number }) => {
seen.push({ item, index });
return <Text key={item}>{item}</Text>;
},
}}
</Static>
<Text>[live]</Text>
</Box>
));
await render(App);
// First batch: two items at absolute indices 0 and 1.
items.value = ["a0", "a1"];
await nextTick();
await new Promise((r) => setTimeout(r, 50));
await nextTick();
// Second batch appended: the new items must get absolute indices 2 and 3
// (their position in the FULL [a0,a1,b2,b3] list), not the batch-local 0,1.
items.value = ["a0", "a1", "b2", "b3"];
await nextTick();
await new Promise((r) => setTimeout(r, 50));
await nextTick();
// Each item was rendered with its absolute index. (Items render once; the
// already-written a0/a1 are sliced out before the second batch renders.)
expect(seen).toEqual([
{ item: "a0", index: 0 },
{ item: "a1", index: 1 },
{ item: "b2", index: 2 },
{ item: "b3", index: 3 },
]);
});
// B04(b) — vertical padding on the <Static> container paints into the static frame.
//
// Static.ts merges the caller `style` onto the internal static box, and
// static-channel.ts paints that node via its OWN yoga node (paintIsolated). So
// paddingTop/paddingBottom resolve as real layout: they add blank rows above /
// below the item inside the painted static frame. Confirmed against the pinned
// Ink reference (v7.0.4): paddingTop:2 paddingBottom:1 → static frame "\n\nX\n\n"
// (2 blank rows above X, then X, then 1 blank row below).
test("Static container vertical padding adds blank rows to the painted static frame", async () => {
const items = shallowRef<string[]>([]);
const App = defineComponent(() => () => (
<Box flexDirection="column">
<Static items={items.value} style={{ paddingTop: 2, paddingBottom: 1 }}>
{{ default: ({ item }: { item: string }) => <Text key={item}>{item}</Text> }}
</Static>
<Text>[live]</Text>
</Box>
));
const { frames } = await render(App);
items.value = ["X"];
await nextTick();
await new Promise((r) => setTimeout(r, 50));
await nextTick();
// The captured static chunk is the "\n"-terminated fullStaticOutput. With
// paddingTop:2 / paddingBottom:1 the painted region is "\n\nX\n\n" (Ink parity).
const staticFrame = frames.find((f) => f.includes("X") && !f.includes("[live]"));
expect(staticFrame).toBeDefined();
expect(staticFrame).toBe("\n\nX\n\n");
});
@@ -289,6 +289,89 @@ test("rawMode 'auto': a no-input app never enables raw mode (Ink lazy behavior)"
app.unmount();
});
// B11 — under rawMode 'auto' the Ink-parity LAZY path is exercised end-to-end:
// useInput ACQUIRES raw mode on mount and RELEASES it when its component
// unmounts. The new 'always' default masks this (its app-lifetime floor ref keeps
// raw mode pinned for the whole session); 'auto' is the escape hatch that restores
// Ink's lazy acquire/release, so this pins the behavior the default hides.
//
// useInput.ts: attach() → stdin.acquireRawMode() on mount; detach() (onScopeDispose
// / isActive→false) → stdin.releaseRawMode(). With NO floor ref under 'auto', the
// last consumer's release drops the refcount to 0 and disables raw mode (back to
// cooked). The disable is deferred to a microtask (render.ts), which settle() drains.
test("rawMode 'auto': useInput acquires raw on mount and releases it on unmount (Ink lazy)", async () => {
const showInput = shallowRef(true);
const Child = defineComponent(() => {
useInput(() => {});
return () => <Text>input</Text>;
});
const App = defineComponent(() => () => (showInput.value ? <Child /> : <Text>idle</Text>));
const stdout = makeFakeWritable();
const { stream: stdin, setRawModeCalls, refCount } = makeSpyStdin();
const app = createApp(App);
app.mount({ stdout, stdin, debug: true, exitOnCtrlC: false, rawMode: "auto" });
await settle();
// Mounting the useInput consumer enables raw mode exactly once (0→1).
expect(setRawModeCalls).toEqual([true]);
expect(refCount()).toBe(1);
// Unmount the ONLY input consumer. Under 'auto' there is no floor ref, so the
// release drops the refcount to 0 and disables raw mode — back to cooked.
showInput.value = false;
await settle();
expect(setRawModeCalls).toEqual([true, false]);
expect(refCount()).toBe(0);
// Teardown must not re-toggle (raw mode is already cooked).
app.unmount();
await settle();
expect(setRawModeCalls).toEqual([true, false]);
expect(refCount()).toBe(0);
});
// B11 — the same lazy release is driven by useInput's isActive gate (not just by
// unmount). Setting isActive false detaches (releaseRawMode → cooked); flipping it
// back true re-attaches (acquireRawMode → raw). This is the per-consumer gating Ink
// exposes via the `isActive` option (use-input.ts), still live under 'auto'.
test("rawMode 'auto': useInput isActive=false releases raw mode, true re-acquires (Ink lazy)", async () => {
const active = shallowRef(true);
const App = defineComponent(() => {
useInput(() => {}, { isActive: () => active.value });
return () => <Text>listening</Text>;
});
const stdout = makeFakeWritable();
const { stream: stdin, setRawModeCalls, refCount } = makeSpyStdin();
const app = createApp(App);
app.mount({ stdout, stdin, debug: true, exitOnCtrlC: false, rawMode: "auto" });
await settle();
// Active on mount → raw acquired once.
expect(setRawModeCalls).toEqual([true]);
expect(refCount()).toBe(1);
// Deactivate → detach releases raw mode (cooked).
active.value = false;
await settle();
expect(setRawModeCalls).toEqual([true, false]);
expect(refCount()).toBe(0);
// Reactivate → re-attach re-acquires raw mode.
active.value = true;
await settle();
expect(setRawModeCalls).toEqual([true, false, true]);
expect(refCount()).toBe(1);
app.unmount();
await settle();
expect(setRawModeCalls).toEqual([true, false, true, false]);
expect(refCount()).toBe(0);
});
// rawMode 'always': raw mode must NOT drop when an input component unmounts
// mid-session — the app's lifetime ref holds the floor at 1, so there is no
// cooked-mode oscillation as the user navigates between input and no-input
@@ -597,6 +597,59 @@ describe("useAnimation", () => {
unmount();
});
// B20: a zero interval must not busy-hang the scheduler — the animation keeps
// ADVANCING (and real wall-clock time progresses) over a short window rather
// than spinning forever or stalling. This is the observable end-to-end
// guarantee ONLY: it deliberately makes NO claim about the exact cadence. The
// exact `Math.max(1, 0) === 1` clamp is pinned directly in the scheduler unit
// test (packages/runtime-tests/unit/animation-scheduler.sequential.test.ts —
// normalizeInterval), which is the discriminating guard against the clamp
// regressing (e.g. to 30ms); a "frame > 1 after 60ms" assertion here cannot
// tell 1ms from 30ms apart, so we do not assert it.
test("interval 0 advances frames without busy-hanging (no cadence claim)", async () => {
let frameVal = 0;
let timeVal = 0;
const App = defineComponent(() => {
const { frame, time } = useAnimation({ interval: 0 });
watchEffect(() => {
frameVal = frame.value;
timeVal = time.value;
});
return () => <Text>{String(frame.value)}</Text>;
});
const { unmount } = await render(App);
// If 0 were NOT clamped to a positive delay, the scheduler would either
// busy-loop (never yielding) or never settle and this test would hang/time
// out. Reaching the assertions at all proves it did neither.
await delay(60);
// The animation advanced (made real progress) ...
expect(frameVal).toBeGreaterThanOrEqual(1);
// ... and real wall-clock time progressed.
expect(timeVal).toBeGreaterThan(0);
unmount();
});
// B20: a negative interval must ALSO not busy-hang — same observable
// advances/no-hang guarantee as interval 0. The exact `Math.max(1, -5) === 1`
// clamp is pinned in the scheduler unit test (normalizeInterval), not here.
test("negative interval (-5) advances frames without busy-hanging (no cadence claim)", async () => {
let frameVal = 0;
let timeVal = 0;
const App = defineComponent(() => {
const { frame, time } = useAnimation({ interval: -5 });
watchEffect(() => {
frameVal = frame.value;
timeVal = time.value;
});
return () => <Text>{String(frame.value)}</Text>;
});
const { unmount } = await render(App);
await delay(60);
expect(frameVal).toBeGreaterThanOrEqual(1);
expect(timeVal).toBeGreaterThan(0);
unmount();
});
// -- Behavior over real time (migrated from fake-timer tests) --
// Timer-precision and exact-frame assertions now live in the scheduler
// unit tests (packages/runtime-tests/unit/animation-scheduler.test.ts).
@@ -0,0 +1,136 @@
// Sequential: these tests mutate the process-GLOBAL env var
// `INK_SCREEN_READER`. The mount path auto-detects it
// (render.ts:526-527: isScreenReaderEnabled =
// options.isScreenReaderEnabled ?? process.env["INK_SCREEN_READER"] === "true").
// Under the repo's file-level parallelism a concurrent sibling in another file
// could observe the mutated value mid-flight (test.sequential only serializes
// WITHIN a file), so per the process-global convention they live here. Each test
// captures the prior value in beforeEach and restores-or-deletes it in afterEach
// so an ambient INK_SCREEN_READER is never blown away.
import { defineComponent, nextTick } from "vue";
import { afterEach, beforeEach, expect, test } from "vite-plus/test";
import { Box, Text, createApp, useIsScreenReaderEnabled } from "@vue-tui/runtime";
import {
makeFakeStdin,
makeFakeWritable,
captureWrites,
getContentWrites,
} from "../lifecycle/test-streams.ts";
// Whether INK_SCREEN_READER was set in the ambient environment, and to what.
let hadEnv = false;
let savedEnv: string | undefined;
beforeEach(() => {
hadEnv = Object.prototype.hasOwnProperty.call(process.env, "INK_SCREEN_READER");
savedEnv = process.env["INK_SCREEN_READER"];
});
afterEach(() => {
// Restore the ORIGINAL state: only re-set the value if it was actually
// present originally; otherwise delete it. Never resurrect/clobber an
// unset-vs-set distinction so an ambient value survives untouched.
if (hadEnv) {
process.env["INK_SCREEN_READER"] = savedEnv;
} else {
delete process.env["INK_SCREEN_READER"];
}
});
test.sequential("INK_SCREEN_READER=true auto-detects SR mode at mount (no explicit option) — linearized output, no border glyphs", async () => {
process.env["INK_SCREEN_READER"] = "true";
const App = defineComponent(() => {
return () => (
<Box borderStyle="round">
<Text>Hello world</Text>
</Box>
);
});
const app = createApp(App);
const stdout = makeFakeWritable({ columns: 80 });
const stderr = makeFakeWritable({ columns: 80 });
const { stream: stdin } = makeFakeStdin();
const writes = captureWrites(stdout);
// NO isScreenReaderEnabled option — the env var alone must enable SR mode.
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false });
await nextTick();
await nextTick();
const content = getContentWrites(writes).join("");
// SR-linearized text is present...
expect(content).toContain("Hello world");
// ...and the 2D box-drawing glyphs are NOT (SR mode linearizes the tree;
// the non-SR path would emit a bordered grid with these glyphs).
for (const glyph of ["╭", "╮", "╰", "╯", "─", "│"]) {
expect(content).not.toContain(glyph);
}
app.unmount();
});
test.sequential("useIsScreenReaderEnabled() returns true under INK_SCREEN_READER=true auto-detection", async () => {
process.env["INK_SCREEN_READER"] = "true";
let observed: boolean | undefined;
const App = defineComponent(() => {
observed = useIsScreenReaderEnabled();
return () => <Text>flag</Text>;
});
const app = createApp(App);
const stdout = makeFakeWritable({ columns: 80 });
const stderr = makeFakeWritable({ columns: 80 });
const { stream: stdin } = makeFakeStdin();
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false });
await nextTick();
await nextTick();
// The composable observed the env-var-derived SR flag as enabled.
expect(observed).toBe(true);
app.unmount();
});
test.sequential("explicit isScreenReaderEnabled:false overrides INK_SCREEN_READER=true (?? only falls back when option is undefined)", async () => {
// Guards the `??` semantics: the env var is the FALLBACK, not an override.
// A live app explicitly opting OUT must render the visual (bordered) frame
// even when INK_SCREEN_READER=true.
process.env["INK_SCREEN_READER"] = "true";
let observed: boolean | undefined;
const App = defineComponent(() => {
observed = useIsScreenReaderEnabled();
return () => (
<Box borderStyle="round">
<Text>Visible</Text>
</Box>
);
});
const app = createApp(App);
const stdout = makeFakeWritable({ columns: 80 });
const stderr = makeFakeWritable({ columns: 80 });
const { stream: stdin } = makeFakeStdin();
const writes = captureWrites(stdout);
// Explicit false must win over the env var.
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false, isScreenReaderEnabled: false });
await nextTick();
await nextTick();
expect(observed).toBe(false);
const content = getContentWrites(writes).join("");
expect(content).toContain("Visible");
// The visual (non-SR) path DOES emit border glyphs.
expect(content).toContain("─");
app.unmount();
});
@@ -1,7 +1,11 @@
import { defineComponent } from "vue";
import { expect, test } from "vite-plus/test";
import { render } from "@vue-tui/testing";
import { Text, useIsScreenReaderEnabled } from "@vue-tui/runtime";
import { Text, renderToString, useIsScreenReaderEnabled } from "@vue-tui/runtime";
// NOTE: tests that auto-detect SR via the process-GLOBAL env var
// `INK_SCREEN_READER` live in use-screen-reader-env.sequential.test.tsx (the
// repo's process-global convention). The tests below never mutate that global.
test("useIsScreenReaderEnabled returns false by default", async () => {
let result = false;
@@ -12,3 +16,19 @@ test("useIsScreenReaderEnabled returns false by default", async () => {
await render(App);
expect(result).toBe(false);
});
// B28: the existing test only covers the `false` default. When SR IS enabled,
// useIsScreenReaderEnabled() must return `true`. It reads ctx.isScreenReaderEnabled
// (useIsScreenReaderEnabled.ts:8), which renderToString seeds from the
// isScreenReaderEnabled option (render-to-string.ts:61-64,179).
test("useIsScreenReaderEnabled returns true when SR is enabled (renderToString option)", () => {
let result: boolean | undefined;
const App = defineComponent(() => {
result = useIsScreenReaderEnabled();
return () => <Text>sr enabled</Text>;
});
const output = renderToString(App, { isScreenReaderEnabled: true });
// Composable observed the enabled flag, and the SR text still rendered.
expect(result).toBe(true);
expect(output).toBe("sr enabled");
});
@@ -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 } from "@vue-tui/runtime";
@@ -43,6 +43,148 @@ test("row gap", async () => {
expect(lastFrame({ trimLines: true })).toBe("A\n\nB");
});
// --- dedicated columnGap / rowGap props (B30, distinct from the `gap` shorthand) ---
//
// Ink semantics (styles.ts applyGapStyles): `columnGap` → yoga GUTTER_COLUMN
// (the gap BETWEEN columns, i.e. horizontal spacing, which is the MAIN-axis gap
// in flexDirection:"row"); `rowGap` → yoga GUTTER_ROW (the gap BETWEEN rows, i.e.
// vertical spacing, the main-axis gap in flexDirection:"column"). vue-tui maps the
// same way (host/yoga.ts: columnGap→GUTTER_COLUMN, rowGap→GUTTER_ROW).
//
// Confirmed against the pinned Ink reference (v7.0.4, /tmp/ink-40b3a75/build,
// renderToString cols=100):
// row + columnGap:2 → "A B"
// column + rowGap:2 → "A\n\n\nB"
// row wrap + columnGap:1 width:3 → "A B\nC"
// row + rowGap:2 → "AB" (cross-axis only; no main-axis effect)
// column + columnGap:2 → "A\nB" (cross-axis only; no main-axis effect)
test("columnGap is the main-axis (horizontal) gap in flexDirection:row", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box flexDirection="row" columnGap={2}>
<Text>A</Text>
<Text>B</Text>
</Box>
)),
{ columns: 100 },
);
// Two spaces between the columns — matches Ink "A B".
expect(lastFrame({ trimLines: true })).toBe("A B");
});
test("rowGap is the main-axis (vertical) gap in flexDirection:column", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box flexDirection="column" rowGap={2}>
<Text>A</Text>
<Text>B</Text>
</Box>
)),
{ columns: 100 },
);
// Two blank rows between A and B — matches Ink "A\n\n\nB".
expect(lastFrame({ trimLines: true })).toBe("A\n\n\nB");
});
test("columnGap spaces wrapped columns horizontally (row + flexWrap)", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box flexDirection="row" columnGap={1} width={3} flexWrap="wrap">
<Text>A</Text>
<Text>B</Text>
<Text>C</Text>
</Box>
)),
{ columns: 100 },
);
// "A" and "B" share the first row with a 1-col gap; "C" wraps. Matches Ink "A B\nC".
expect(lastFrame({ trimLines: true })).toBe("A B\nC");
});
test("rowGap has no main-axis effect in flexDirection:row (cross-axis only)", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box flexDirection="row" rowGap={2}>
<Text>A</Text>
<Text>B</Text>
</Box>
)),
{ columns: 100 },
);
// rowGap is the BETWEEN-ROWS gap; a single-row layout has no rows to separate,
// so it adds nothing. Matches Ink "AB".
expect(lastFrame({ trimLines: true })).toBe("AB");
});
test("columnGap has no main-axis effect in flexDirection:column (cross-axis only)", async () => {
const { lastFrame } = await render(
defineComponent(() => () => (
<Box flexDirection="column" columnGap={2}>
<Text>A</Text>
<Text>B</Text>
</Box>
)),
{ columns: 100 },
);
// columnGap is the BETWEEN-COLUMNS gap; a single-column layout has no columns to
// separate, so it adds nothing. Matches Ink "A\nB".
expect(lastFrame({ trimLines: true })).toBe("A\nB");
});
// Prop-reset: removing columnGap (undefined) or setting it to 0 must collapse the
// horizontal spacing back to the no-gap baseline. yoga.ts resets the gutter to 0
// when the prop is null/undefined (G19), so the layout must re-flow tight.
test("columnGap resets when removed (undefined) or set to 0", async () => {
const gap = shallowRef<number | undefined>(2);
const { lastFrame } = await render(
defineComponent(() => () => (
<Box flexDirection="row" columnGap={gap.value}>
<Text>A</Text>
<Text>B</Text>
</Box>
)),
{ columns: 100 },
);
expect(lastFrame({ trimLines: true })).toBe("A B");
// Remove the prop entirely → gutter resets to 0, columns abut.
gap.value = undefined;
await nextTick();
expect(lastFrame({ trimLines: true })).toBe("AB");
// Re-apply, then explicitly set 0 → also tight.
gap.value = 3;
await nextTick();
expect(lastFrame({ trimLines: true })).toBe("A B");
gap.value = 0;
await nextTick();
expect(lastFrame({ trimLines: true })).toBe("AB");
});
// Prop-reset for rowGap in the vertical direction.
test("rowGap resets when removed (undefined) or set to 0", async () => {
const gap = shallowRef<number | undefined>(2);
const { lastFrame } = await render(
defineComponent(() => () => (
<Box flexDirection="column" rowGap={gap.value}>
<Text>A</Text>
<Text>B</Text>
</Box>
)),
{ columns: 100 },
);
expect(lastFrame({ trimLines: true })).toBe("A\n\n\nB");
gap.value = undefined;
await nextTick();
expect(lastFrame({ trimLines: true })).toBe("A\nB");
gap.value = 0;
await nextTick();
expect(lastFrame({ trimLines: true })).toBe("A\nB");
});
// Skipped: gap - concurrent
// Skipped: column gap - concurrent
// Skipped: row gap - concurrent
@@ -309,6 +309,68 @@ describe("cursor commit-path wiring (interactive stream level)", () => {
}
});
test("a child useCursor unmount emits the cursor-HIDE escape (show -> hide ordering)", async () => {
// B19 (Ink parity, use-cursor.ts:29-31): when a `useCursor` child unmounts,
// its onScopeDispose runs ctx.setCursorPosition(undefined) — exactly Ink's
// useInsertionEffect cleanup `context.setCursorPosition(undefined)`. That
// marks log-update cursorDirty with an undefined position. On the NEXT
// commit, the frame changed (child swapped to a no-cursor branch) so render()
// takes the `else` branch and writes buildReturnToBottomPrefix(cursorWasShown:
// true, ...) — which begins with hideCursorEscape (`\x1b[?25l`). So the cursor
// that was SHOWN at the child's position must be HIDDEN when the owner unmounts.
//
// This is observable only at the interactive stream level: the debug render()
// helper has FrameWriter.log === null, so log-update (and its cursor escapes)
// never runs. We capture raw stdout write chunks (Ink's getWriteCalls pattern).
const showChild = shallowRef(true);
const CursorChild = defineComponent(() => {
const { setCursorPosition } = useCursor();
return () => {
// Distinct cursor column so the SHOW is unambiguous: x=5 -> cursorTo(5).
setCursorPosition({ x: 5, y: 0 });
return <Text>child</Text>;
};
});
const HostApp = defineComponent(() => {
return () => <Box>{showChild.value ? <CursorChild /> : <Text>no cursor here</Text>}</Box>;
});
const { stream: stdout, writes } = makeTtyStdout();
const stdin = makeTtyStdin();
const app = createApp(HostApp);
app.mount({ stdout, stdin, exitOnCtrlC: false, maxFps: 0 });
await app.waitUntilRenderFlush();
// While the child is mounted the cursor is SHOWN at its position (x=5).
const beforeUnmount = writes.join("");
expect(beforeUnmount).toContain(showCursorEscape);
expect(beforeUnmount).toContain(cursorTo(5));
// The last visibility change so far is a SHOW (the active cursor).
expect(beforeUnmount.lastIndexOf(showCursorEscape)).toBeGreaterThan(
beforeUnmount.lastIndexOf(hideCursorEscape),
);
// Unmount the cursor owner. onScopeDispose -> setCursorPosition(undefined);
// the next commit must HIDE the previously-shown cursor.
const writesBeforeUnmount = writes.length;
showChild.value = false;
await nextTick();
await app.waitUntilRenderFlush();
// A HIDE escape must have been emitted on the unmount commit, and it must be
// the LAST visibility change (the cursor is now gone — not re-shown).
const unmountWrites = writes.slice(writesBeforeUnmount).join("");
expect(unmountWrites).toContain(hideCursorEscape);
const fullOutput = writes.join("");
expect(fullOutput.lastIndexOf(hideCursorEscape)).toBeGreaterThan(
fullOutput.lastIndexOf(showCursorEscape),
);
app.unmount();
});
test("a synchronous mount throw rethrows the ORIGINAL error even if cursor-restore also throws", async () => {
// DEFECT 2b (Codex review): the mount path tears down on a synchronous
// throw to re-show the cursor, but teardown's restore (mountedWriter.done()
@@ -16,6 +16,11 @@ import {
useStdin,
useStdout,
useStderr,
useCursor,
usePaste,
useTerminalSize,
useAnimation,
useBoxMetrics,
} from "@vue-tui/runtime";
describe("renderToString", () => {
@@ -637,4 +642,102 @@ describe("renderToString", () => {
// "[hi]" glyphs are RAW (no bg SGR); only the trailing Box-fill padding is green.
expect(renderToString(App, { columns: 100 })).toBe("[hi]" + chalk.bgGreen(" "));
});
// ── B29: renderToString serves the TERMINAL composables with inert no-op
// contexts ──────────────────────────────────────────────────────────────
//
// renderToString runs with NO terminal session: it provides no-op AppContext +
// StdinContext + a no-op AnimationScheduler (render-to-string.ts:93-96). The
// existing suite covers useInput/useApp/useFocus/useFocusManager/useStdin/
// useStdout/useStderr. These pin the remaining terminal composables —
// useCursor, usePaste, useTerminalSize, useAnimation, useBoxMetrics — so that
// rendering a component which CALLS them degrades to inert values instead of
// throwing (they must still return a string).
describe("terminal composables degrade to no-ops (do not throw)", () => {
test("useCursor does not throw in renderToString", () => {
const App = defineComponent(() => {
// setCursorPosition forwards to the no-op AppContext.setCursorPosition.
const { setCursorPosition } = useCursor();
setCursorPosition({ x: 2, y: 0 });
return () => <Text>with cursor</Text>;
});
const output = renderToString(App);
expect(output).toBe("with cursor");
});
test("usePaste does not throw in renderToString", () => {
let pasted = "";
const App = defineComponent(() => {
// usePaste injects StdinContext (no-op here) and attaches to its
// internal_eventEmitter — no terminal session, so the handler never fires.
usePaste((text) => {
pasted = text;
});
return () => <Text>with paste</Text>;
});
const output = renderToString(App);
expect(output).toBe("with paste");
// The no-op stdin never emits a paste, so the handler stayed inert.
expect(pasted).toBe("");
});
test("useTerminalSize does not throw in renderToString", () => {
const App = defineComponent(() => {
// Resolves dimensions from ctx.stdout (process.stdout in the no-op
// context) with the terminal-size fallback; never throws.
const { columns, rows } = useTerminalSize();
return () => <Text>size {columns.value > 0 && rows.value > 0 ? "ok" : "fallback"}</Text>;
});
const output = renderToString(App);
expect(output).toContain("size");
});
test("useAnimation does not throw in renderToString (frame frozen at 0)", () => {
const App = defineComponent(() => {
// The no-op AnimationScheduler never ticks, so frame stays 0 and no timer
// leaks (subscribe returns an inert unsubscribe).
const { frame } = useAnimation({ interval: 50 });
return () => <Text>{`frame:${frame.value}`}</Text>;
});
const output = renderToString(App);
expect(output).toBe("frame:0");
});
test("useBoxMetrics does not throw in renderToString", () => {
const App = defineComponent(() => {
// useBoxMetrics tracks a Box ref via the root layout listener. In the
// synchronous renderToString teardown the post-flush measurement may not
// have run, so hasMeasured can still be false — the point is it must NOT
// throw and the frame must still render.
const boxRef = shallowRef(null);
const { hasMeasured } = useBoxMetrics(boxRef);
return () => (
<Box ref={boxRef}>
<Text>{hasMeasured.value ? "measured" : "metrics"}</Text>
</Box>
);
});
const output = renderToString(App, { columns: 40 });
expect(output).toContain("metrics");
});
test("all five terminal composables together render to a string without throwing", () => {
const App = defineComponent(() => {
const { setCursorPosition } = useCursor();
setCursorPosition({ x: 1, y: 0 });
usePaste(() => {});
useTerminalSize();
const { frame } = useAnimation({ interval: 30 });
const boxRef = shallowRef(null);
useBoxMetrics(boxRef);
return () => (
<Box ref={boxRef}>
<Text>{`all:${frame.value}`}</Text>
</Box>
);
});
const output = renderToString(App, { columns: 40 });
expect(output).toContain("all:0");
});
});
});
@@ -14,6 +14,7 @@ describe.sequential("normalizeInterval", () => {
test("clamps and defaults", () => {
expect(normalizeInterval(50)).toBe(50);
expect(normalizeInterval(0)).toBe(1);
expect(normalizeInterval(-5)).toBe(1);
expect(normalizeInterval(-10)).toBe(1);
expect(normalizeInterval(undefined)).toBe(100);
expect(normalizeInterval(Number.NaN)).toBe(100);