Files
vue-tui/packages/runtime-tests/integration/composables/use-cursor.test.tsx
T
Yunfei He 9227ddf696 test: add Ink component/composable test parity (+130) and fix layout listener bug
* test: add text ANSI sanitization parity tests from Ink (+15)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: add use-animation parity tests from Ink (+43)

Port 43 new tests from Ink's use-animation test suite covering:
- Multiple animations in sync, different rates, sibling unmount
- Timer cleanup/recreation on unmount and remount
- Inactive animations, timer leak prevention
- Edge intervals (NaN, Infinity, -Infinity, oversized, zero, negative)
- isActive toggle resets, pause/resume cycles
- Frame catch-up, time/delta tracking, reset() behavior
- Newly mounted/activated animations don't inherit elapsed time
- Wall clock monotonicity, getter function isActive support

Uses selective fake timers (setInterval + performance only) so that
render()'s internal setImmediate still works on real clocks. Fake timer
tests read refs directly to avoid Vue scheduler flush timing issues.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: add use-box-metrics/measure parity tests from Ink (+16)

Port 16 missing tests from Ink's use-box-metrics, measure-element, and
measure-text test suites. Fix useBoxMetrics to reset metrics to zeros
when the tracked ref detaches (element unmounts or ref switches to null).

3 tests are skipped because vue-tui's useBoxMetrics uses watchPostEffect
(re-runs only when ref.value changes) rather than Ink's layout-commit
listener pattern, so sibling-content and resize-driven re-measurement
is not yet supported.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: add screen-reader parity tests from Ink (+11)

Add 11 screen-reader integration tests covering aria-label substitution on
Text/Box, ANSI styling omission, multiple/nested components, null component,
aria-state variants (busy, disabled, expanded), multi-line roles, and
multiselectable listbox.

Also fix component prop bug: Vue normalizes kebab-case prop names to camelCase
at runtime, so props["aria-label"] was always undefined. Switch Box/Text prop
declarations and access to camelCase (ariaLabel, ariaHidden, ariaRole, ariaState).

Add isScreenReaderEnabled option to renderToString() so tests can exercise
screen-reader output through the component pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: add render-to-string parity tests from Ink (+18)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: add cursor composable parity tests from Ink (+7)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: add 6 missing screen-reader Ink parity tests

Add tests for aria-hidden, select input (list with roles/states/labels),
aria-state.multiline, aria-state.readonly, aria-state.required, and
nested multi-line text rendering in screen-reader mode.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: fix render-to-string missing Ink parity tests (+10)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: fix cursor composable missing Ink parity tests (+6)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: fix use-box-metrics missing Ink parity tests (+4)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: add layout listener so useBoxMetrics updates on resize and sibling changes

Adds a layout listener mechanism to TuiRoot matching Ink's architecture:
- TuiRoot.layoutListeners Set with addLayoutListener/emitLayoutListeners
- emitLayoutListeners called after every yoga.calculateLayout in commit()
- useBoxMetrics subscribes to layout listeners, diffs values before updating

Enables 4 previously-skipped tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 20:42:54 +08:00

516 lines
17 KiB
TypeScript

import { PassThrough } from "node:stream";
import { defineComponent, nextTick, onMounted, shallowRef, watch } from "vue";
import { describe, expect, test } from "vite-plus/test";
import { render } from "@vue-tui/testing";
import { Box, Text, createApp, useCursor, useInput, useStdout, useStderr } from "@vue-tui/runtime";
describe("useCursor", () => {
test("setCursorPosition updates cursor state", async () => {
const App = defineComponent(() => {
const { setCursorPosition } = useCursor();
setCursorPosition({ x: 5, y: 3 });
return () => <Text>cursor test</Text>;
});
const { lastFrame } = await render(App);
expect(lastFrame()).toContain("cursor test");
});
test("setCursorPosition can be updated reactively", async () => {
const pos = shallowRef<{ x: number; y: number } | undefined>({ x: 0, y: 0 });
const App = defineComponent(() => {
const { setCursorPosition } = useCursor();
setCursorPosition(pos.value);
return () => <Text>reactive</Text>;
});
const { lastFrame } = await render(App);
expect(lastFrame()).toContain("reactive");
// Update position — should not crash
pos.value = { x: 10, y: 5 };
await nextTick();
});
test("setCursorPosition accepts undefined to hide cursor", async () => {
const App = defineComponent(() => {
const { setCursorPosition } = useCursor();
setCursorPosition({ x: 1, y: 1 });
setCursorPosition(undefined);
return () => <Text>hidden</Text>;
});
const { lastFrame } = await render(App);
expect(lastFrame()).toContain("hidden");
});
test("cursor is cleared on unmount", async () => {
const App = defineComponent(() => {
useCursor();
return () => <Text>cursor</Text>;
});
const { unmount } = await render(App);
unmount();
// No crash = success (cursor position cleared via onScopeDispose)
});
test("throws when called outside render tree", () => {
expect(() => useCursor()).toThrow("useCursor() must be called inside a vue-tui render tree");
});
// --- Parity tests ported from Ink cursor.tsx ---
test("cursor follows text input", async () => {
let capturedX = -1;
const App = defineComponent(() => {
const text = shallowRef("");
const { setCursorPosition } = useCursor();
useInput((input, key) => {
if (key.backspace || key.delete) {
text.value = text.value.slice(0, -1);
return;
}
if (!key.ctrl && !key.meta && input) {
text.value = text.value + input;
}
});
return () => {
// Set cursor position during render so it updates with each frame
setCursorPosition({ x: 2 + text.value.length, y: 0 });
capturedX = 2 + text.value.length;
return (
<Box>
<Text>{`> ${text.value}`}</Text>
</Box>
);
};
});
const { lastFrame, stdin } = await render(App);
expect(lastFrame()).toBe(">");
expect(capturedX).toBe(2);
await stdin.write("a");
expect(lastFrame()).toContain("> a");
// After typing 'a', cursor should be at x=3 ("> a" = 3 chars)
expect(capturedX).toBe(3);
});
test("cursor moves on space input even when output may look similar", async () => {
let capturedX = -1;
const App = defineComponent(() => {
const text = shallowRef("");
const { setCursorPosition } = useCursor();
useInput((input, key) => {
if (!key.ctrl && !key.meta && input) {
text.value = text.value + input;
}
});
return () => {
setCursorPosition({ x: 2 + text.value.length, y: 0 });
capturedX = 2 + text.value.length;
return (
<Box>
<Text>{`> ${text.value}`}</Text>
</Box>
);
};
});
const { stdin } = await render(App);
expect(capturedX).toBe(2);
await stdin.write("a");
expect(capturedX).toBe(3);
await stdin.write(" ");
// After "a ", cursor should be at x=4
expect(capturedX).toBe(4);
});
test("cursor is cleared when child component using useCursor unmounts", async () => {
const CursorChild = defineComponent(() => {
const { setCursorPosition } = useCursor();
setCursorPosition({ x: 5, y: 0 });
return () => <Text>child</Text>;
});
const showChild = shallowRef(true);
const App = defineComponent(() => {
return () => <Box>{showChild.value ? <CursorChild /> : <Text>no cursor</Text>}</Box>;
});
// After the child unmounts, onScopeDispose fires ctx.setCursorPosition(undefined).
// We verify the frame updates correctly.
const { lastFrame } = await render(App);
expect(lastFrame()).toContain("child");
showChild.value = false;
await nextTick();
await nextTick();
expect(lastFrame()).toContain("no cursor");
// The child's onScopeDispose should have cleared the cursor.
// No crash = success. The cursor position is cleared in the AppContext.
});
test("screen does not scroll on subsequent renders with multi-line cursor", async () => {
const App = defineComponent(() => {
const text = shallowRef("");
const { setCursorPosition } = useCursor();
useInput((input, key) => {
if (!key.ctrl && !key.meta && input) {
text.value = text.value + input;
}
});
return () => {
// Cursor on line 1 (second line)
setCursorPosition({ x: 2 + text.value.length, y: 1 });
return (
<Box flexDirection="column">
<Text>Header</Text>
<Text>{`> ${text.value}`}</Text>
</Box>
);
};
});
const { lastFrame, stdin, frames } = await render(App);
expect(lastFrame()).toContain("Header");
expect(lastFrame()).toContain(">");
const frameCountBefore = frames.length;
await stdin.write("x");
expect(lastFrame()).toContain("> x");
// A new frame was rendered (no scroll-induced blank frames)
expect(frames.length).toBeGreaterThan(frameCountBefore);
// Both lines are still present after re-render
expect(lastFrame()).toContain("Header");
});
test("useStdout().write() does not corrupt frame when cursor is active", async () => {
const App = defineComponent(() => {
const { setCursorPosition } = useCursor();
const { write } = useStdout();
setCursorPosition({ x: 2, y: 0 });
onMounted(() => {
write("from stdout hook\n");
});
return () => <Text>Hello</Text>;
});
const { lastFrame } = await render(App);
// Frame should still be intact despite the stdout write
expect(lastFrame()).toContain("Hello");
});
test("useStderr().write() does not corrupt frame when cursor is active", async () => {
const App = defineComponent(() => {
const { setCursorPosition } = useCursor();
const { write } = useStderr();
setCursorPosition({ x: 2, y: 0 });
onMounted(() => {
write("from stderr hook\n");
});
return () => <Text>Hello</Text>;
});
const { lastFrame } = await render(App);
// Frame should still be intact despite the stderr write
expect(lastFrame()).toContain("Hello");
});
test("debug mode: useStdout().write() replays frame after write with cursor active", async () => {
let writeCount = 0;
const App = defineComponent(() => {
const { setCursorPosition } = useCursor();
const { write, stdout } = useStdout();
setCursorPosition({ x: 2, y: 0 });
// Track stdout writes
const origWrite = stdout.write.bind(stdout);
stdout.write = ((data: string, ...args: unknown[]) => {
if (typeof data === "string" && data.length > 0) {
writeCount++;
}
return (origWrite as Function)(data, ...args);
}) as typeof stdout.write;
onMounted(() => {
write("from stdout hook\n");
});
return () => <Text>Hello</Text>;
});
const { lastFrame } = await render(App);
expect(lastFrame()).toContain("Hello");
// In debug mode, writeToStdout writes: data + fullStaticOutput + lastOutput.
// This means after the hook write, the frame should be replayed.
// At minimum, there should be more than one write (initial render + replay).
expect(writeCount).toBeGreaterThan(1);
});
// --- Ink parity: cursor position verified at stream level ---
test("cursor is shown at specified position after render", async () => {
// Verifies the cursor position set via useCursor() is propagated to the
// app context after first render, mirroring Ink's test that checks for
// showCursorEscape + cursorTo(2) in stdout writes.
let capturedPosition: { x: number; y: number } | undefined;
const App = defineComponent(() => {
const { setCursorPosition } = useCursor();
setCursorPosition({ x: 2, y: 0 });
return () => {
capturedPosition = { x: 2, y: 0 };
return (
<Box>
<Text>{"> "}</Text>
</Box>
);
};
});
const { lastFrame } = await render(App);
expect(lastFrame()).toContain(">");
// The cursor position was set during setup and remains after render
expect(capturedPosition).toEqual({ x: 2, y: 0 });
});
test("cursor is not hidden by lifecycle hooks after first render", async () => {
// Mirrors Ink's "cursor is not hidden by useEffect after first render".
// Ensures that onMounted or other lifecycle hooks don't override the
// cursor position set during setup.
let positionAfterMount: { x: number; y: number } | undefined;
const App = defineComponent(() => {
const { setCursorPosition } = useCursor();
const position = shallowRef<{ x: number; y: number } | undefined>({ x: 5, y: 0 });
setCursorPosition(position.value);
onMounted(() => {
// After mount, the cursor position should still be what we set in setup
positionAfterMount = position.value;
});
return () => <Text>cursor active</Text>;
});
const { lastFrame } = await render(App);
expect(lastFrame()).toContain("cursor active");
// The onMounted hook should NOT have cleared the cursor position
expect(positionAfterMount).toEqual({ x: 5, y: 0 });
});
// --- Ink parity: debug mode stream isolation and replay ---
//
// These tests use createApp directly to access both stdout and stderr
// streams, mirroring the Ink tests that verify debug-mode write behavior.
/**
* Helper: mount a component via createApp in debug mode with access to
* both stdout and stderr streams.
*/
function mountDebug(component: ReturnType<typeof defineComponent>) {
const stdout = new PassThrough() as unknown as NodeJS.WriteStream;
Object.assign(stdout, { columns: 100, rows: 100, isTTY: true });
const stderr = new PassThrough() as unknown as NodeJS.WriteStream;
Object.assign(stderr, { columns: 100, rows: 100, isTTY: true });
const stdin = new PassThrough() as unknown as NodeJS.ReadStream;
Object.assign(stdin, {
isTTY: true,
setRawMode() {
return stdin;
},
setEncoding() {
return stdin;
},
ref() {},
unref() {},
});
const stdoutWrites: string[] = [];
const stderrWrites: string[] = [];
stdout.on("data", (chunk) => stdoutWrites.push(chunk.toString()));
stderr.on("data", (chunk) => stderrWrites.push(chunk.toString()));
const app = createApp(component);
app.mount({ stdout, stdin, stderr, debug: true, exitOnCtrlC: false });
return {
stdoutWrites,
stderrWrites,
unmount: () => app.unmount(),
};
}
test("debug mode: useStdout().write() does not leak into stderr", async () => {
const App = defineComponent(() => {
const { write } = useStdout();
onMounted(() => {
write("from stdout hook\n");
});
return () => <Text>Hello</Text>;
});
const { stdoutWrites, stderrWrites, unmount } = mountDebug(App);
await nextTick();
await nextTick();
await new Promise<void>((r) => setImmediate(r));
// stdout should contain the hook write + frame replay
expect(stdoutWrites.some((w) => w.includes("from stdout hook"))).toBe(true);
expect(stdoutWrites.some((w) => w.includes("Hello"))).toBe(true);
// stderr must NOT receive stdout data or frame content
expect(stderrWrites.some((w) => w.includes("from stdout hook"))).toBe(false);
expect(stderrWrites.some((w) => w.includes("Hello"))).toBe(false);
// No empty writes on stderr
expect(stderrWrites.includes("")).toBe(false);
unmount();
});
test("debug mode: useStderr().write() replays latest frame without empty writes", async () => {
const App = defineComponent(() => {
const { write } = useStderr();
onMounted(() => {
write("from stderr hook\n");
});
return () => <Text>Hello</Text>;
});
const { stdoutWrites, stderrWrites, unmount } = mountDebug(App);
await nextTick();
await nextTick();
await new Promise<void>((r) => setImmediate(r));
// stderr should contain the hook write data
expect(stderrWrites.some((w) => w.includes("from stderr hook"))).toBe(true);
// stderr should NOT contain frame content
expect(stderrWrites.some((w) => w.includes("Hello"))).toBe(false);
// stdout should replay the frame after stderr write
// (initial render + replay after stderr write)
const stdoutWritesAfterInitial = stdoutWrites.slice(1);
expect(stdoutWritesAfterInitial.length).toBeGreaterThan(0);
expect(stdoutWritesAfterInitial.some((w) => w.includes("Hello"))).toBe(true);
// No writes should contain stderr data on stdout
expect(stdoutWritesAfterInitial.some((w) => w.includes("from stderr hook"))).toBe(false);
// No empty writes on either stream
expect(stdoutWrites.includes("")).toBe(false);
expect(stderrWrites.includes("")).toBe(false);
unmount();
});
test("debug mode: useStdout().write() replays rerendered frame", async () => {
const text = shallowRef("Initial");
let triggerWrite: (() => void) | undefined;
const App = defineComponent(() => {
const { write } = useStdout();
onMounted(() => {
text.value = "Updated";
});
watch(text, (val) => {
if (val === "Updated") {
triggerWrite = () => write("from stdout hook\n");
}
});
return () => <Text>{text.value}</Text>;
});
const { stdoutWrites, unmount } = mountDebug(App);
await nextTick();
await nextTick();
await new Promise<void>((r) => setImmediate(r));
// Trigger the write after re-render
triggerWrite?.();
await nextTick();
// The write should replay the UPDATED frame, not the initial one
expect(stdoutWrites.some((w) => w.includes("from stdout hook") && w.includes("Updated"))).toBe(
true,
);
// Should NOT replay the stale initial frame alongside the write
expect(stdoutWrites.some((w) => w.includes("from stdout hook") && w.includes("Initial"))).toBe(
false,
);
// No empty writes
expect(stdoutWrites.includes("")).toBe(false);
unmount();
});
test("debug mode: useStderr().write() replays rerendered frame", async () => {
const text = shallowRef("Initial");
let triggerWrite: (() => void) | undefined;
const App = defineComponent(() => {
const { write } = useStderr();
onMounted(() => {
text.value = "Updated";
});
watch(text, (val) => {
if (val === "Updated") {
triggerWrite = () => write("from stderr hook\n");
}
});
return () => <Text>{text.value}</Text>;
});
const { stdoutWrites, stderrWrites, unmount } = mountDebug(App);
await nextTick();
await nextTick();
await new Promise<void>((r) => setImmediate(r));
// Trigger the write after re-render
triggerWrite?.();
await nextTick();
// stderr should contain the hook data
expect(stderrWrites.some((w) => w.includes("from stderr hook"))).toBe(true);
// stderr should NOT contain frame content
expect(stderrWrites.some((w) => w.includes("Updated"))).toBe(false);
expect(stderrWrites.some((w) => w.includes("Initial"))).toBe(false);
// stdout should replay the UPDATED frame after stderr write
const stdoutWritesAfterInitial = stdoutWrites.slice(1);
expect(stdoutWritesAfterInitial.some((w) => w.includes("Updated"))).toBe(true);
// Should NOT replay the stale initial frame
expect(stdoutWritesAfterInitial.some((w) => w.includes("Initial"))).toBe(false);
// stderr data should NOT appear on stdout
expect(stdoutWritesAfterInitial.some((w) => w.includes("from stderr hook"))).toBe(false);
// No empty writes
expect(stdoutWrites.includes("")).toBe(false);
expect(stderrWrites.includes("")).toBe(false);
unmount();
});
});