test(runtime): lock vt220 fn-keys, terminal-size fallbacks, animation offsets, arrow meta (#114)

Round-2 test-only locks (behaviors already at parity with Ink):
- parse-keypress (Ink parse-keypress.ts): Ctrl+F1–F4 (\x1b[1;5P/Q/R/S → f1–f4 ctrl),
  unmapped ctrl (\x1b[1;5I/X → name '' ctrl), Shift+F1 (\x1b[1;2P → f1 shift).
- terminal-size (Ink terminal-resize.tsx): 0-columns → positive fallback;
  resize-listener returns to baseline on unmount; env.LINES rows fallback (a .sequential
  file — mutates process.env/stdout; deletes absent env vars in teardown to avoid pollution).
- use-animation (Ink use-animation.tsx, a .sequential file with deterministic fake timers):
  newly mounted/activated same-interval animations don't inherit elapsed time
  (firstFrame - secondFrame === 1); a re-render with an unchanged interval doesn't reset
  the frame (forced via an unrelated reactive dep — a same-value assign is a Vue no-op);
  reset is a stable reference across re-renders (collected in the render fn).
- use-input: plain arrows assert key.meta === false (Ink's `&& !key.meta` gate).

Codex-reviewed; the same-value-no-reset (was vacuous) and env teardown (left "undefined")
were tightened per its notes.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-06-01 02:49:31 +08:00
committed by GitHub
parent eebbb37118
commit a78fd67cea
5 changed files with 461 additions and 1 deletions
@@ -0,0 +1,86 @@
// Sequential: mutates process-global state — process.env.COLUMNS/LINES and
// process.stdout/process.stderr columns+rows. The terminal-size package (used by
// resolveSize's fallback) reads these globals directly, so a concurrent sibling
// would perturb the result. Tests restore every mutated prop in a finally block.
import { PassThrough } from "node:stream";
import process from "node:process";
import { defineComponent } from "vue";
import { expect, test } from "vite-plus/test";
import { createApp, Text, useTerminalSize } from "@vue-tui/runtime";
function makeTtyStream(columns: number): NodeJS.WriteStream {
const s = new PassThrough() as unknown as NodeJS.WriteStream;
// Deliberately no `rows` — forces resolveSize() into the terminal-size fallback.
Object.assign(s, { columns, isTTY: true });
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;
}
// Mirrors Ink terminal-resize.tsx:110-152 ("falls back to terminal-size rows
// when stdout.rows is missing"). With the mount stdout reporting columns 0 and
// no rows, resolveSize() calls terminal-size, which — after we zero out the real
// process.stdout/stderr dimensions — resolves rows from process.env.LINES.
test.sequential("useTerminalSize falls back to terminal-size rows from env.LINES when stdout.rows is missing", async () => {
const stdout = makeTtyStream(0);
const stderr = makeTtyStream(0);
const stdin = makeFakeStdin();
const originalColumns = process.env.COLUMNS;
const originalLines = process.env.LINES;
const originalStdoutColumns = process.stdout.columns;
const originalStdoutRows = process.stdout.rows;
const originalStderrColumns = process.stderr.columns;
const originalStderrRows = process.stderr.rows;
let capturedRows = -1;
const App = defineComponent(() => {
const { rows } = useTerminalSize();
capturedRows = rows.value;
return () => <Text>{String(rows.value)}</Text>;
});
const app = createApp(App);
try {
// terminal-size prefers process.stdout/stderr dimensions, then env.
// Zero the real streams so env.COLUMNS/LINES is the winning source.
process.env.COLUMNS = "123";
process.env.LINES = "45";
process.stdout.columns = 0;
process.stdout.rows = 0;
process.stderr.columns = 0;
process.stderr.rows = 0;
app.mount({ stdout, stdin, stderr, debug: true, exitOnCtrlC: false });
await new Promise<void>((r) => setTimeout(r, 60));
expect(capturedRows).toBe(45);
} finally {
app.unmount();
// Restore env precisely: a var that was ABSENT must be DELETED, not set to
// the string "undefined" (which would pollute later tests' CI/size detection).
if (originalColumns === undefined) delete process.env.COLUMNS;
else process.env.COLUMNS = originalColumns;
if (originalLines === undefined) delete process.env.LINES;
else process.env.LINES = originalLines;
process.stdout.columns = originalStdoutColumns;
process.stdout.rows = originalStdoutRows;
process.stderr.columns = originalStderrColumns;
process.stderr.rows = originalStderrRows;
}
});
@@ -1,7 +1,35 @@
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, Text, useTerminalSize } from "@vue-tui/runtime";
import { Box, createApp, Text, useTerminalSize } 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("useTerminalSize reacts to resize event", async () => {
const App = defineComponent(() => {
@@ -160,3 +188,59 @@ test("resize listener is cleaned up via onScopeDispose", async () => {
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("useTerminalSize 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 } = useTerminalSize();
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 useTerminalSize component
// and return exactly to baseline after unmount (no leaked listener).
test("useTerminalSize 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 } = useTerminalSize();
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);
});
@@ -0,0 +1,225 @@
// Sequential: uses vi.useFakeTimers (process-global setTimeout/performance
// mocking) to lock EXACT frame offsets deterministically — the same offsets Ink
// asserts (test/use-animation.tsx) but which real wall-clock timers can only
// approximate. The composable's scheduler reads performance.now()/setTimeout, so
// faking both makes frame = floor((now - startTime) / interval) fully reproducible.
import { PassThrough } from "node:stream";
import { defineComponent, nextTick, shallowRef, watchEffect } from "vue";
import { afterEach, beforeEach, describe, expect, test, vi } from "vite-plus/test";
import { createApp, Text, useAnimation } from "@vue-tui/runtime";
function makeStreams() {
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() {},
});
return { stdout, stderr, stdin };
}
// Flush Vue's microtask-based reactivity (fake timers do NOT gate microtasks),
// then nextTick so the committed frame value settles after a scheduler tick.
async function flush() {
await Promise.resolve();
await nextTick();
}
describe.sequential("useAnimation exact frame offsets (deterministic)", () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance"] });
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
// Ink test/use-animation.tsx:1080-1135 ("newly mounted animations do not
// inherit elapsed time"). A second animation appearing one interval after the
// first starts from frame 0 and stays EXACTLY one frame behind.
test("a newly mounted same-interval animation starts at frame 0 and stays one frame behind", async () => {
const interval = 20;
const showSecond = shallowRef(false);
let firstFrame = 0;
let secondFrame = 0;
const First = defineComponent(() => {
const { frame } = useAnimation({ interval });
watchEffect(() => {
firstFrame = frame.value;
});
return () => <Text>{String(frame.value)}</Text>;
});
const Second = defineComponent(() => {
const { frame } = useAnimation({ interval });
watchEffect(() => {
secondFrame = frame.value;
});
return () => <Text>{String(frame.value)}</Text>;
});
const App = defineComponent(() => {
return () => (
<>
<First />
{showSecond.value ? <Second /> : <Text>-</Text>}
</>
);
});
const { stdout, stderr, stdin } = makeStreams();
const app = createApp(App);
app.mount({ stdout, stdin, stderr, debug: true, exitOnCtrlC: false });
await flush();
// Advance just past one interval, then mount the second animation. The first
// is now at frame 1; the second subscribes at this moment → frame 0.
await vi.advanceTimersByTimeAsync(25);
showSecond.value = true;
await flush();
expect(firstFrame).toBe(1);
expect(secondFrame).toBe(0);
// Advance two more intervals: first → 3, second → 2. Exactly one apart.
await vi.advanceTimersByTimeAsync(40);
await flush();
expect(firstFrame).toBeGreaterThanOrEqual(2);
expect(secondFrame).toBeGreaterThanOrEqual(1);
expect(firstFrame - secondFrame).toBe(1);
app.unmount();
});
// Ink test/use-animation.tsx:1137-1201 ("newly activated animations do not
// inherit elapsed time"). Same exact-offset lock, but the second animation is
// mounted from the start and only ACTIVATED one interval later.
test("a newly activated same-interval animation starts at frame 0 and stays one frame behind", async () => {
const interval = 20;
const secondActive = shallowRef(false);
let firstFrame = 0;
let secondFrame = 0;
const App = defineComponent(() => {
const { frame: f1 } = useAnimation({ interval });
const { frame: f2 } = useAnimation({ interval, isActive: secondActive });
watchEffect(() => {
firstFrame = f1.value;
secondFrame = f2.value;
});
return () => (
<Text>
{f1.value},{f2.value}
</Text>
);
});
const { stdout, stderr, stdin } = makeStreams();
const app = createApp(App);
app.mount({ stdout, stdin, stderr, debug: true, exitOnCtrlC: false });
await flush();
await vi.advanceTimersByTimeAsync(25);
secondActive.value = true;
await flush();
expect(firstFrame).toBe(1);
expect(secondFrame).toBe(0);
await vi.advanceTimersByTimeAsync(40);
await flush();
expect(firstFrame).toBeGreaterThanOrEqual(2);
expect(secondFrame).toBeGreaterThanOrEqual(1);
expect(firstFrame - secondFrame).toBe(1);
app.unmount();
});
// Ink test/use-animation.tsx:1203-1238 ("rerendering with the same interval
// does not reset the frame"). A re-render that leaves the interval unchanged must
// NOT restart timing. We force a genuine re-render via an UNRELATED reactive dep
// (`bump`) the render reads — assigning the SAME interval value would be a no-op in
// Vue (the watcher only fires on change), so it wouldn't exercise anything. The
// interval ref stays 50 across the re-render; the frame must keep its value.
test("a re-render with an unchanged interval does not reset the frame", async () => {
const interval = shallowRef(50);
const bump = shallowRef(0);
let frameVal = 0;
const App = defineComponent(() => {
const { frame } = useAnimation({ interval });
watchEffect(() => {
frameVal = frame.value;
});
// Read `bump` in the render so changing it forces a real re-render.
return () => <Text>{`${frame.value}:${bump.value}`}</Text>;
});
const { stdout, stderr, stdin } = makeStreams();
const app = createApp(App);
app.mount({ stdout, stdin, stderr, debug: true, exitOnCtrlC: false });
await flush();
// Advance past frame 1.
await vi.advanceTimersByTimeAsync(120);
await flush();
const frameBefore = frameVal;
expect(frameBefore).toBeGreaterThanOrEqual(1);
// Force a re-render with the interval unchanged. A bug that reset on re-render
// would drop frameVal to 0; correct behavior keeps it at frameBefore.
bump.value++;
await flush();
expect(frameVal).toBe(frameBefore);
app.unmount();
});
// Ink test/use-animation.tsx:1356-1378 ("reset is a stable function
// reference"). reset() identity must survive re-renders. Ink re-runs the
// component body each render and collects reset every time; in Vue setup()
// runs once, so we collect reset inside the RENDER function (which DOES re-run
// per render) to exercise the same "observed across renders" property.
test("reset is a stable function reference across re-renders", async () => {
const tick = shallowRef(0);
const resets: Array<() => void> = [];
const App = defineComponent(() => {
const { reset } = useAnimation({ interval: 50 });
// Read tick.value in render so changing it forces a re-render; collect the
// reset reference on every render pass.
return () => {
resets.push(reset);
return <Text>{String(tick.value)}</Text>;
};
});
const { stdout, stderr, stdin } = makeStreams();
const app = createApp(App);
app.mount({ stdout, stdin, stderr, debug: true, exitOnCtrlC: false });
await flush();
tick.value = 1;
await flush();
tick.value = 2;
await flush();
expect(resets.length).toBeGreaterThanOrEqual(2);
expect(resets[0]).toBe(resets.at(-1));
app.unmount();
});
});
@@ -121,6 +121,9 @@ test("useInput - handle up arrow", async () => {
const { stdin } = await render(App);
await stdin.write("\x1b[A");
expect(calls[0]?.key.upArrow).toBe(true);
// Ink fixtures/use-input.tsx:111 gate on `key.upArrow && !key.meta`; lock that
// a plain arrow never spuriously sets meta.
expect(calls[0]?.key.meta).toBe(false);
});
test("useInput - handle down arrow", async () => {
@@ -133,6 +136,8 @@ test("useInput - handle down arrow", async () => {
const { stdin } = await render(App);
await stdin.write("\x1b[B");
expect(calls[0]?.key.downArrow).toBe(true);
// Ink fixtures/use-input.tsx:116 gate on `key.downArrow && !key.meta`.
expect(calls[0]?.key.meta).toBe(false);
});
test("useInput - handle right arrow", async () => {
@@ -145,6 +150,8 @@ test("useInput - handle right arrow", async () => {
const { stdin } = await render(App);
await stdin.write("\x1b[C");
expect(calls[0]?.key.rightArrow).toBe(true);
// Ink fixtures/use-input.tsx:126 gate on `key.rightArrow && !key.meta`.
expect(calls[0]?.key.meta).toBe(false);
});
test("useInput - handle left arrow", async () => {
@@ -157,6 +164,8 @@ test("useInput - handle left arrow", async () => {
const { stdin } = await render(App);
await stdin.write("\x1b[D");
expect(calls[0]?.key.leftArrow).toBe(true);
// Ink fixtures/use-input.tsx:121 gate on `key.leftArrow && !key.meta`.
expect(calls[0]?.key.meta).toBe(false);
});
test("useInput - handles rapid arrows and enter in one chunk per write", async () => {
@@ -88,4 +88,60 @@ describe("parse-keypress", () => {
expect(key.name).toBe("a");
expect(key.shift).toBe(true);
});
// --- vt220-style Ctrl+F1–F4 (ESC [ 1 ; 5 P/Q/R/S) ---
// Mirrors Ink test/parse-keypress.ts:5-29. These come through the fnKeyRe
// path (modifier 5 → ctrl) against the "[P".."[S" → f1..f4 keyName map.
test("Ctrl+F1 resolves to name f1", () => {
const key = parseKeypress("\x1b[1;5P");
expect(key.name).toBe("f1");
expect(key.ctrl).toBe(true);
expect(key.shift).toBe(false);
expect(key.meta).toBe(false);
});
test("Ctrl+F2 resolves to name f2", () => {
const key = parseKeypress("\x1b[1;5Q");
expect(key.name).toBe("f2");
expect(key.ctrl).toBe(true);
});
test("Ctrl+F3 resolves to name f3", () => {
const key = parseKeypress("\x1b[1;5R");
expect(key.name).toBe("f3");
expect(key.ctrl).toBe(true);
});
test("Ctrl+F4 resolves to name f4", () => {
const key = parseKeypress("\x1b[1;5S");
expect(key.name).toBe("f4");
expect(key.ctrl).toBe(true);
});
// --- Unmapped ctrl-modifier sequences fall back to empty name ---
// Mirrors Ink test/parse-keypress.ts:32-42. The fnKeyRe matches but the code
// (e.g. "[I", "[X") has no keyName entry, so name is "" while ctrl stays true.
test("unmapped ctrl sequence returns empty name", () => {
const key = parseKeypress("\x1b[1;5I");
expect(key.name).toBe("");
expect(key.ctrl).toBe(true);
});
test("another unmapped ctrl sequence returns empty name", () => {
const key = parseKeypress("\x1b[1;5X");
expect(key.name).toBe("");
expect(key.ctrl).toBe(true);
});
// --- Shift+F1 (modifier 2) uses the same [P mapping ---
// Mirrors Ink test/parse-keypress.ts:45-50.
test("Shift+F1 resolves to name f1 with shift", () => {
const key = parseKeypress("\x1b[1;2P");
expect(key.name).toBe("f1");
expect(key.shift).toBe(true);
expect(key.ctrl).toBe(false);
});
});