From a78fd67cea454bfedcdf910c911766ebe9131df1 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Mon, 1 Jun 2026 02:49:31 +0800 Subject: [PATCH] test(runtime): lock vt220 fn-keys, terminal-size fallbacks, animation offsets, arrow meta (#114) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../terminal-size.sequential.test.tsx | 86 +++++++ .../composables/terminal-size.test.tsx | 86 ++++++- .../use-animation-offsets.sequential.test.tsx | 225 ++++++++++++++++++ .../composables/use-input.test.tsx | 9 + .../runtime/src/io/parse-keypress.test.ts | 56 +++++ 5 files changed, 461 insertions(+), 1 deletion(-) create mode 100644 packages/runtime-tests/integration/composables/terminal-size.sequential.test.tsx create mode 100644 packages/runtime-tests/integration/composables/use-animation-offsets.sequential.test.tsx diff --git a/packages/runtime-tests/integration/composables/terminal-size.sequential.test.tsx b/packages/runtime-tests/integration/composables/terminal-size.sequential.test.tsx new file mode 100644 index 0000000..04c8845 --- /dev/null +++ b/packages/runtime-tests/integration/composables/terminal-size.sequential.test.tsx @@ -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 () => {String(rows.value)}; + }); + + 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((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; + } +}); diff --git a/packages/runtime-tests/integration/composables/terminal-size.test.tsx b/packages/runtime-tests/integration/composables/terminal-size.test.tsx index 45bcede..2b053a6 100644 --- a/packages/runtime-tests/integration/composables/terminal-size.test.tsx +++ b/packages/runtime-tests/integration/composables/terminal-size.test.tsx @@ -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 () => {String(columns.value)}; + }); + + const app = createApp(App); + app.mount({ stdout, stdin, stderr, debug: true, exitOnCtrlC: false }); + await new Promise((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 () => ( + + {columns.value}x{rows.value} + + ); + }); + + const app = createApp(App); + app.mount({ stdout, stdin, stderr, debug: true, exitOnCtrlC: false }); + await new Promise((r) => setTimeout(r, 60)); + + expect(stdout.listenerCount("resize")).toBeGreaterThan(baseline); + + app.unmount(); + expect(stdout.listenerCount("resize")).toBe(baseline); +}); diff --git a/packages/runtime-tests/integration/composables/use-animation-offsets.sequential.test.tsx b/packages/runtime-tests/integration/composables/use-animation-offsets.sequential.test.tsx new file mode 100644 index 0000000..b8ffa0d --- /dev/null +++ b/packages/runtime-tests/integration/composables/use-animation-offsets.sequential.test.tsx @@ -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 () => {String(frame.value)}; + }); + const Second = defineComponent(() => { + const { frame } = useAnimation({ interval }); + watchEffect(() => { + secondFrame = frame.value; + }); + return () => {String(frame.value)}; + }); + const App = defineComponent(() => { + return () => ( + <> + + {showSecond.value ? : -} + + ); + }); + + 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 () => ( + + {f1.value},{f2.value} + + ); + }); + + 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 () => {`${frame.value}:${bump.value}`}; + }); + + 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 {String(tick.value)}; + }; + }); + + 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(); + }); +}); diff --git a/packages/runtime-tests/integration/composables/use-input.test.tsx b/packages/runtime-tests/integration/composables/use-input.test.tsx index 6627958..b998d49 100644 --- a/packages/runtime-tests/integration/composables/use-input.test.tsx +++ b/packages/runtime-tests/integration/composables/use-input.test.tsx @@ -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 () => { diff --git a/packages/runtime/src/io/parse-keypress.test.ts b/packages/runtime/src/io/parse-keypress.test.ts index 234976a..7364468 100644 --- a/packages/runtime/src/io/parse-keypress.test.ts +++ b/packages/runtime/src/io/parse-keypress.test.ts @@ -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); + }); });