f847e17c81
Follow-up cleanup for the 7 confirmed findings from a review of #163. The dominant theme: #163 hard-renamed the public composable useTerminalSize -> useWindowSize (no alias) but left stale references to the dead name in user-facing docs. - README.md + packages/runtime/README.md: the composable tables named the removed `useTerminalSize()` (root README even framed the sole real export `useWindowSize` as an "Ink-compat alias" — now inverted). Point both at `useWindowSize()`. - .agents/docs/ink-divergences.md: two vue-tui-side references to `useTerminalSize` (the shallowRef "object of refs" example and the "composables throw outside a render tree" list) -> `useWindowSize`. The Ink-side `useWindowSize -> WindowSize` naming example is left unchanged. - .agents/docs/accessibility-api.md: the intro cited three "blessed entries" but only aria-camelCase is one; `renderToString` layout-only and the `useWindowSize` name are now Ink parity, not divergences. Reword. - .agents/docs/api-contract.md: tighten the `/internal` wording — the test does assert one tripwire on `/internal`, so "not covered by public-api.test.ts" was imprecise. - public-api.test.ts / render-to-string.test.tsx: the public renderToString dropped the `isScreenReaderEnabled` option but (unlike the sibling `ScreenReaderOptions` type) had no compile-time guard. Replace an obscure, fmt-fragile type-indexing guard with a readable call-site `@ts-expect-error` in render-to-string.test.tsx; re-adding the option to the public RenderToStringOptions makes the directive unused and fails `tsc --noEmit`. - Rename terminal-size.test.tsx / .sequential.test.tsx -> window-size.test.tsx / .sequential.test.tsx to match the migrated symbol. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
247 lines
7.5 KiB
TypeScript
247 lines
7.5 KiB
TypeScript
import { PassThrough } from "node:stream";
|
|
import { defineComponent, onScopeDispose } from "vue";
|
|
import { expect, test } from "vite-plus/test";
|
|
import { render } from "@vue-tui/testing";
|
|
import { Box, createApp, Text, useWindowSize } from "@vue-tui/runtime";
|
|
|
|
// A TTY-like writable that we control directly (columns/rows + resize listeners)
|
|
// — the @vue-tui/testing render() helper hides the underlying stdout, but the
|
|
// fallback-and-listener locks below need to read listenerCount('resize') and
|
|
// mount with columns:0 / no rows, mirroring Ink's createStdout-based fixtures.
|
|
function makeTtyStream(columns: number, rows?: number): NodeJS.WriteStream {
|
|
const s = new PassThrough() as unknown as NodeJS.WriteStream;
|
|
Object.assign(s, { columns, isTTY: true });
|
|
if (rows !== undefined) (s as unknown as { rows: number }).rows = rows;
|
|
return s;
|
|
}
|
|
|
|
function makeFakeStdin(): NodeJS.ReadStream {
|
|
const s = new PassThrough() as unknown as NodeJS.ReadStream;
|
|
Object.assign(s, {
|
|
isTTY: true,
|
|
setRawMode() {
|
|
return s;
|
|
},
|
|
setEncoding() {
|
|
return s;
|
|
},
|
|
});
|
|
(s as unknown as { ref: () => void }).ref = () => {};
|
|
(s as unknown as { unref: () => void }).unref = () => {};
|
|
return s;
|
|
}
|
|
|
|
test("useWindowSize reacts to resize event", async () => {
|
|
const App = defineComponent(() => {
|
|
const { columns, rows } = useWindowSize();
|
|
return () => (
|
|
<Text>
|
|
{columns.value}x{rows.value}
|
|
</Text>
|
|
);
|
|
});
|
|
|
|
const { lastFrame, terminal } = await render(App, { columns: 80, rows: 24 });
|
|
expect(lastFrame()).toContain("80x24");
|
|
|
|
await terminal.resize(120, 40);
|
|
expect(lastFrame()).toContain("120x40");
|
|
});
|
|
|
|
test("useWindowSize returns initial terminal dimensions", async () => {
|
|
const App = defineComponent(() => {
|
|
const { columns, rows } = useWindowSize();
|
|
return () => (
|
|
<Text>
|
|
{columns.value}x{rows.value}
|
|
</Text>
|
|
);
|
|
});
|
|
|
|
const { lastFrame } = await render(App, { columns: 100, rows: 40 });
|
|
expect(lastFrame()).toContain("100x40");
|
|
});
|
|
|
|
test("useWindowSize removes resize listener on unmount", async () => {
|
|
// After unmount, further resize events should not cause errors
|
|
const App = defineComponent(() => {
|
|
const { columns, rows } = useWindowSize();
|
|
return () => (
|
|
<Text>
|
|
{columns.value}x{rows.value}
|
|
</Text>
|
|
);
|
|
});
|
|
|
|
const { lastFrame, unmount, terminal } = await render(App, { columns: 80, rows: 24 });
|
|
expect(lastFrame()).toContain("80x24");
|
|
|
|
unmount();
|
|
|
|
// Resize after unmount should not throw
|
|
await expect(terminal.resize(60, 20)).resolves.toBeUndefined();
|
|
});
|
|
|
|
test("useWindowSize does not crash when resize fires after unmount", async () => {
|
|
const App = defineComponent(() => {
|
|
const { columns, rows } = useWindowSize();
|
|
return () => (
|
|
<Text>
|
|
{columns.value}x{rows.value}
|
|
</Text>
|
|
);
|
|
});
|
|
|
|
const { unmount, terminal } = await render(App, { columns: 80, rows: 24 });
|
|
unmount();
|
|
|
|
// Emitting resize after unmount should not crash
|
|
await terminal.resize(60, 20);
|
|
// If we reach here without throwing, the test passes
|
|
});
|
|
|
|
test("layout responds to terminal width change", async () => {
|
|
const App = defineComponent(() => {
|
|
return () => (
|
|
<Box borderStyle="round">
|
|
<Text>Hello World</Text>
|
|
</Box>
|
|
);
|
|
});
|
|
|
|
const { lastFrame, terminal } = await render(App, { columns: 100, rows: 24 });
|
|
const initialFrame = lastFrame()!;
|
|
expect(initialFrame).toContain("Hello World");
|
|
|
|
await terminal.resize(50, 24);
|
|
const resizedFrame = lastFrame()!;
|
|
expect(resizedFrame).toContain("Hello World");
|
|
// Output should differ because column count changed
|
|
expect(initialFrame).not.toBe(resizedFrame);
|
|
});
|
|
|
|
test("multiple consecutive resizes all take effect", async () => {
|
|
const App = defineComponent(() => {
|
|
const { columns, rows } = useWindowSize();
|
|
return () => (
|
|
<Text>
|
|
{columns.value}x{rows.value}
|
|
</Text>
|
|
);
|
|
});
|
|
|
|
const { lastFrame, terminal } = await render(App, { columns: 80, rows: 24 });
|
|
expect(lastFrame()).toContain("80x24");
|
|
|
|
await terminal.resize(100, 30);
|
|
expect(lastFrame()).toContain("100x30");
|
|
|
|
await terminal.resize(60, 20);
|
|
expect(lastFrame()).toContain("60x20");
|
|
|
|
await terminal.resize(120, 40);
|
|
expect(lastFrame()).toContain("120x40");
|
|
});
|
|
|
|
test("terminal width decrease triggers rerender", async () => {
|
|
const App = defineComponent(() => {
|
|
const { columns } = useWindowSize();
|
|
return () => <Text>{columns.value}</Text>;
|
|
});
|
|
|
|
const { lastFrame, terminal } = await render(App, { columns: 100, rows: 24 });
|
|
expect(lastFrame()).toContain("100");
|
|
|
|
await terminal.resize(50, 24);
|
|
expect(lastFrame()).toContain("50");
|
|
});
|
|
|
|
test("terminal width increase triggers rerender", async () => {
|
|
const App = defineComponent(() => {
|
|
const { columns } = useWindowSize();
|
|
return () => <Text>{columns.value}</Text>;
|
|
});
|
|
|
|
const { lastFrame, terminal } = await render(App, { columns: 50, rows: 24 });
|
|
expect(lastFrame()).toContain("50");
|
|
|
|
await terminal.resize(100, 24);
|
|
expect(lastFrame()).toContain("100");
|
|
});
|
|
|
|
test("resize listener is cleaned up via onScopeDispose", async () => {
|
|
let disposeCalled = false;
|
|
|
|
const App = defineComponent(() => {
|
|
// useWindowSize registers an onScopeDispose listener internally;
|
|
// we also register one to verify the scope is properly disposed on unmount.
|
|
useWindowSize();
|
|
onScopeDispose(() => {
|
|
disposeCalled = true;
|
|
});
|
|
return () => <Text>watching</Text>;
|
|
});
|
|
|
|
const { unmount } = await render(App, { columns: 80, rows: 24 });
|
|
expect(disposeCalled).toBe(false);
|
|
|
|
unmount();
|
|
expect(disposeCalled).toBe(true);
|
|
});
|
|
|
|
// Mirrors Ink terminal-resize.tsx:91-108 ("falls back to a positive column
|
|
// count when stdout.columns is 0"). When the mount stdout reports columns 0,
|
|
// resolveSize() falls through to the terminal-size package / 80 default, so the
|
|
// captured value must be a positive number (never 0).
|
|
test("useWindowSize falls back to a positive column count when stdout.columns is 0", async () => {
|
|
const stdout = makeTtyStream(0, 24);
|
|
const stderr = makeTtyStream(0, 24);
|
|
const stdin = makeFakeStdin();
|
|
|
|
let capturedColumns = -1;
|
|
const App = defineComponent(() => {
|
|
const { columns } = useWindowSize();
|
|
capturedColumns = columns.value;
|
|
return () => <Text>{String(columns.value)}</Text>;
|
|
});
|
|
|
|
const app = createApp(App);
|
|
app.mount({ stdout, stdin, stderr, debug: true, exitOnCtrlC: false });
|
|
await new Promise<void>((r) => setTimeout(r, 60));
|
|
|
|
try {
|
|
expect(capturedColumns).toBeGreaterThan(0);
|
|
} finally {
|
|
app.unmount();
|
|
}
|
|
});
|
|
|
|
// Mirrors Ink terminal-resize.tsx:43-64 ("removes resize listener on unmount").
|
|
// The resize listener count must grow by mounting a useWindowSize component
|
|
// and return exactly to baseline after unmount (no leaked listener).
|
|
test("useWindowSize resize listener returns to baseline on unmount", async () => {
|
|
const stdout = makeTtyStream(80, 24);
|
|
const stderr = makeTtyStream(80, 24);
|
|
const stdin = makeFakeStdin();
|
|
|
|
const baseline = stdout.listenerCount("resize");
|
|
|
|
const App = defineComponent(() => {
|
|
const { columns, rows } = useWindowSize();
|
|
return () => (
|
|
<Text>
|
|
{columns.value}x{rows.value}
|
|
</Text>
|
|
);
|
|
});
|
|
|
|
const app = createApp(App);
|
|
app.mount({ stdout, stdin, stderr, debug: true, exitOnCtrlC: false });
|
|
await new Promise<void>((r) => setTimeout(r, 60));
|
|
|
|
expect(stdout.listenerCount("resize")).toBeGreaterThan(baseline);
|
|
|
|
app.unmount();
|
|
expect(stdout.listenerCount("resize")).toBe(baseline);
|
|
});
|