9227ddf696
* 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>
516 lines
17 KiB
TypeScript
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();
|
|
});
|
|
});
|