From c8c2c4a32d0871456c3c5bffc4f7b0ceab9fbe1b Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Wed, 3 Jun 2026 03:42:33 +0800 Subject: [PATCH] test: backfill Ink-parity coverage gaps (suite as a superset of Ink) (#130) Regression tests for behaviors the audit found correct-but-unpinned, so the suite is a strict superset of Ink: - A07: two regions both render (the additive divergence) - B04: Static render-prop index = absolute index across appends; container vertical padding adds blank rows to the static frame - B11: lazy raw-mode acquire/release under rawMode:'auto' (the path the 'always' default masks) - B19: child useCursor unmount emits the cursor-hide escape (stream-level) - B20: animation interval 0/negative clamps to 1ms (normalizeInterval unit) and advances without busy-hang - B21/B28: INK_SCREEN_READER env auto-detection + useIsScreenReaderEnabled true-path (env tests isolated in a *.sequential file per the global-state rule) - B29: renderToString serves useCursor/usePaste/useTerminalSize/useAnimation/ useBoxMetrics as inert no-ops (don't throw) - B30: dedicated columnGap/rowGap props + their removal-reset Test-only; no production changes. Codex-reviewed for non-vacuousness, Ink correctness, and process-global isolation. Co-authored-by: Claude Opus 4.8 (1M context) --- .../integration/components/static.test.tsx | 126 +++++++++++++++ .../composables/raw-mode-lifecycle.test.tsx | 83 ++++++++++ .../composables/use-animation.test.tsx | 53 +++++++ .../use-screen-reader-env.sequential.test.tsx | 136 +++++++++++++++++ .../composables/use-screen-reader.test.tsx | 22 ++- .../integration/layout/gap.test.tsx | 144 +++++++++++++++++- .../lifecycle/cursor-commit-path.test.tsx | 62 ++++++++ .../integration/render-to-string.test.tsx | 103 +++++++++++++ .../animation-scheduler.sequential.test.ts | 1 + 9 files changed, 728 insertions(+), 2 deletions(-) create mode 100644 packages/runtime-tests/integration/composables/use-screen-reader-env.sequential.test.tsx diff --git a/packages/runtime-tests/integration/components/static.test.tsx b/packages/runtime-tests/integration/components/static.test.tsx index 78d46e0..3e84c04 100644 --- a/packages/runtime-tests/integration/components/static.test.tsx +++ b/packages/runtime-tests/integration/components/static.test.tsx @@ -774,3 +774,129 @@ test("Static items do not add blank lines to the dynamic frame", async () => { const frame = lastFrame()!; expect(frame).toBe("[live]"); }); + +// A07 — multiple regions both render. +// +// Ink honors only the FIRST in the tree (ink.tsx tracks a single +// `staticNode`); a second one is silently dropped. vue-tui is an ADDITIVE +// DIVERGENCE: every node found by findStatics() is painted, so two +// independent static regions in one tree BOTH emit their items. This pins that +// superset behavior (there was previously no test mounting two regions). +test("two separate regions both render their items (additive divergence)", async () => { + const headerItems = shallowRef([]); + const logItems = shallowRef([]); + + const App = defineComponent(() => () => ( + + + {{ default: ({ item }: { item: string }) => {item} }} + + + {{ default: ({ item }: { item: string }) => {item} }} + + [live] + + )); + + const { frames } = await render(App); + + // Populate both regions; each region's items must appear in the emitted output. + headerItems.value = ["HEADER-1", "HEADER-2"]; + logItems.value = ["LOG-1", "LOG-2"]; + await nextTick(); + await new Promise((r) => setTimeout(r, 50)); + await nextTick(); + + const allOutput = frames.join(""); + // BOTH regions render (Ink would drop the second). + expect(allOutput).toContain("HEADER-1"); + expect(allOutput).toContain("HEADER-2"); + expect(allOutput).toContain("LOG-1"); + expect(allOutput).toContain("LOG-2"); +}); + +// B04(a) — the render-prop's SECOND arg (`index`) is the ABSOLUTE array index, +// stable across INCREMENTAL appends. +// +// Static.ts renders `items.slice(cursor)` and passes `index = cursor + i`, where +// the cursor advances to items.length after each batch is written. So an item's +// index is its position in the FULL list, never its position within the append +// batch. This mirrors Ink's Static, which renders `items.slice(index)` and passes +// the absolute index to the render prop. (Confirmed: batch [a0,a1] → indices 0,1; +// batch [b2,b3] appended → indices 2,3, NOT 0,1.) +test("Static render-prop index is the absolute array index across incremental appends", async () => { + const seen: Array<{ item: string; index: number }> = []; + const items = shallowRef([]); + + const App = defineComponent(() => () => ( + + + {{ + default: ({ item, index }: { item: string; index: number }) => { + seen.push({ item, index }); + return {item}; + }, + }} + + [live] + + )); + + await render(App); + + // First batch: two items at absolute indices 0 and 1. + items.value = ["a0", "a1"]; + await nextTick(); + await new Promise((r) => setTimeout(r, 50)); + await nextTick(); + + // Second batch appended: the new items must get absolute indices 2 and 3 + // (their position in the FULL [a0,a1,b2,b3] list), not the batch-local 0,1. + items.value = ["a0", "a1", "b2", "b3"]; + await nextTick(); + await new Promise((r) => setTimeout(r, 50)); + await nextTick(); + + // Each item was rendered with its absolute index. (Items render once; the + // already-written a0/a1 are sliced out before the second batch renders.) + expect(seen).toEqual([ + { item: "a0", index: 0 }, + { item: "a1", index: 1 }, + { item: "b2", index: 2 }, + { item: "b3", index: 3 }, + ]); +}); + +// B04(b) — vertical padding on the container paints into the static frame. +// +// Static.ts merges the caller `style` onto the internal static box, and +// static-channel.ts paints that node via its OWN yoga node (paintIsolated). So +// paddingTop/paddingBottom resolve as real layout: they add blank rows above / +// below the item inside the painted static frame. Confirmed against the pinned +// Ink reference (v7.0.4): paddingTop:2 paddingBottom:1 → static frame "\n\nX\n\n" +// (2 blank rows above X, then X, then 1 blank row below). +test("Static container vertical padding adds blank rows to the painted static frame", async () => { + const items = shallowRef([]); + + const App = defineComponent(() => () => ( + + + {{ default: ({ item }: { item: string }) => {item} }} + + [live] + + )); + + const { frames } = await render(App); + + items.value = ["X"]; + await nextTick(); + await new Promise((r) => setTimeout(r, 50)); + await nextTick(); + + // The captured static chunk is the "\n"-terminated fullStaticOutput. With + // paddingTop:2 / paddingBottom:1 the painted region is "\n\nX\n\n" (Ink parity). + const staticFrame = frames.find((f) => f.includes("X") && !f.includes("[live]")); + expect(staticFrame).toBeDefined(); + expect(staticFrame).toBe("\n\nX\n\n"); +}); diff --git a/packages/runtime-tests/integration/composables/raw-mode-lifecycle.test.tsx b/packages/runtime-tests/integration/composables/raw-mode-lifecycle.test.tsx index 832e81b..b2771e0 100644 --- a/packages/runtime-tests/integration/composables/raw-mode-lifecycle.test.tsx +++ b/packages/runtime-tests/integration/composables/raw-mode-lifecycle.test.tsx @@ -289,6 +289,89 @@ test("rawMode 'auto': a no-input app never enables raw mode (Ink lazy behavior)" app.unmount(); }); +// B11 — under rawMode 'auto' the Ink-parity LAZY path is exercised end-to-end: +// useInput ACQUIRES raw mode on mount and RELEASES it when its component +// unmounts. The new 'always' default masks this (its app-lifetime floor ref keeps +// raw mode pinned for the whole session); 'auto' is the escape hatch that restores +// Ink's lazy acquire/release, so this pins the behavior the default hides. +// +// useInput.ts: attach() → stdin.acquireRawMode() on mount; detach() (onScopeDispose +// / isActive→false) → stdin.releaseRawMode(). With NO floor ref under 'auto', the +// last consumer's release drops the refcount to 0 and disables raw mode (back to +// cooked). The disable is deferred to a microtask (render.ts), which settle() drains. +test("rawMode 'auto': useInput acquires raw on mount and releases it on unmount (Ink lazy)", async () => { + const showInput = shallowRef(true); + const Child = defineComponent(() => { + useInput(() => {}); + return () => input; + }); + const App = defineComponent(() => () => (showInput.value ? : idle)); + + const stdout = makeFakeWritable(); + const { stream: stdin, setRawModeCalls, refCount } = makeSpyStdin(); + + const app = createApp(App); + app.mount({ stdout, stdin, debug: true, exitOnCtrlC: false, rawMode: "auto" }); + await settle(); + + // Mounting the useInput consumer enables raw mode exactly once (0→1). + expect(setRawModeCalls).toEqual([true]); + expect(refCount()).toBe(1); + + // Unmount the ONLY input consumer. Under 'auto' there is no floor ref, so the + // release drops the refcount to 0 and disables raw mode — back to cooked. + showInput.value = false; + await settle(); + expect(setRawModeCalls).toEqual([true, false]); + expect(refCount()).toBe(0); + + // Teardown must not re-toggle (raw mode is already cooked). + app.unmount(); + await settle(); + expect(setRawModeCalls).toEqual([true, false]); + expect(refCount()).toBe(0); +}); + +// B11 — the same lazy release is driven by useInput's isActive gate (not just by +// unmount). Setting isActive false detaches (releaseRawMode → cooked); flipping it +// back true re-attaches (acquireRawMode → raw). This is the per-consumer gating Ink +// exposes via the `isActive` option (use-input.ts), still live under 'auto'. +test("rawMode 'auto': useInput isActive=false releases raw mode, true re-acquires (Ink lazy)", async () => { + const active = shallowRef(true); + const App = defineComponent(() => { + useInput(() => {}, { isActive: () => active.value }); + return () => listening; + }); + + const stdout = makeFakeWritable(); + const { stream: stdin, setRawModeCalls, refCount } = makeSpyStdin(); + + const app = createApp(App); + app.mount({ stdout, stdin, debug: true, exitOnCtrlC: false, rawMode: "auto" }); + await settle(); + + // Active on mount → raw acquired once. + expect(setRawModeCalls).toEqual([true]); + expect(refCount()).toBe(1); + + // Deactivate → detach releases raw mode (cooked). + active.value = false; + await settle(); + expect(setRawModeCalls).toEqual([true, false]); + expect(refCount()).toBe(0); + + // Reactivate → re-attach re-acquires raw mode. + active.value = true; + await settle(); + expect(setRawModeCalls).toEqual([true, false, true]); + expect(refCount()).toBe(1); + + app.unmount(); + await settle(); + expect(setRawModeCalls).toEqual([true, false, true, false]); + expect(refCount()).toBe(0); +}); + // rawMode 'always': raw mode must NOT drop when an input component unmounts // mid-session — the app's lifetime ref holds the floor at 1, so there is no // cooked-mode oscillation as the user navigates between input and no-input diff --git a/packages/runtime-tests/integration/composables/use-animation.test.tsx b/packages/runtime-tests/integration/composables/use-animation.test.tsx index ee826e8..5460154 100644 --- a/packages/runtime-tests/integration/composables/use-animation.test.tsx +++ b/packages/runtime-tests/integration/composables/use-animation.test.tsx @@ -597,6 +597,59 @@ describe("useAnimation", () => { unmount(); }); + // B20: a zero interval must not busy-hang the scheduler — the animation keeps + // ADVANCING (and real wall-clock time progresses) over a short window rather + // than spinning forever or stalling. This is the observable end-to-end + // guarantee ONLY: it deliberately makes NO claim about the exact cadence. The + // exact `Math.max(1, 0) === 1` clamp is pinned directly in the scheduler unit + // test (packages/runtime-tests/unit/animation-scheduler.sequential.test.ts — + // normalizeInterval), which is the discriminating guard against the clamp + // regressing (e.g. to 30ms); a "frame > 1 after 60ms" assertion here cannot + // tell 1ms from 30ms apart, so we do not assert it. + test("interval 0 advances frames without busy-hanging (no cadence claim)", async () => { + let frameVal = 0; + let timeVal = 0; + const App = defineComponent(() => { + const { frame, time } = useAnimation({ interval: 0 }); + watchEffect(() => { + frameVal = frame.value; + timeVal = time.value; + }); + return () => {String(frame.value)}; + }); + const { unmount } = await render(App); + // If 0 were NOT clamped to a positive delay, the scheduler would either + // busy-loop (never yielding) or never settle and this test would hang/time + // out. Reaching the assertions at all proves it did neither. + await delay(60); + // The animation advanced (made real progress) ... + expect(frameVal).toBeGreaterThanOrEqual(1); + // ... and real wall-clock time progressed. + expect(timeVal).toBeGreaterThan(0); + unmount(); + }); + + // B20: a negative interval must ALSO not busy-hang — same observable + // advances/no-hang guarantee as interval 0. The exact `Math.max(1, -5) === 1` + // clamp is pinned in the scheduler unit test (normalizeInterval), not here. + test("negative interval (-5) advances frames without busy-hanging (no cadence claim)", async () => { + let frameVal = 0; + let timeVal = 0; + const App = defineComponent(() => { + const { frame, time } = useAnimation({ interval: -5 }); + watchEffect(() => { + frameVal = frame.value; + timeVal = time.value; + }); + return () => {String(frame.value)}; + }); + const { unmount } = await render(App); + await delay(60); + expect(frameVal).toBeGreaterThanOrEqual(1); + expect(timeVal).toBeGreaterThan(0); + unmount(); + }); + // -- Behavior over real time (migrated from fake-timer tests) -- // Timer-precision and exact-frame assertions now live in the scheduler // unit tests (packages/runtime-tests/unit/animation-scheduler.test.ts). diff --git a/packages/runtime-tests/integration/composables/use-screen-reader-env.sequential.test.tsx b/packages/runtime-tests/integration/composables/use-screen-reader-env.sequential.test.tsx new file mode 100644 index 0000000..17db586 --- /dev/null +++ b/packages/runtime-tests/integration/composables/use-screen-reader-env.sequential.test.tsx @@ -0,0 +1,136 @@ +// Sequential: these tests mutate the process-GLOBAL env var +// `INK_SCREEN_READER`. The mount path auto-detects it +// (render.ts:526-527: isScreenReaderEnabled = +// options.isScreenReaderEnabled ?? process.env["INK_SCREEN_READER"] === "true"). +// Under the repo's file-level parallelism a concurrent sibling in another file +// could observe the mutated value mid-flight (test.sequential only serializes +// WITHIN a file), so per the process-global convention they live here. Each test +// captures the prior value in beforeEach and restores-or-deletes it in afterEach +// so an ambient INK_SCREEN_READER is never blown away. + +import { defineComponent, nextTick } from "vue"; +import { afterEach, beforeEach, expect, test } from "vite-plus/test"; +import { Box, Text, createApp, useIsScreenReaderEnabled } from "@vue-tui/runtime"; +import { + makeFakeStdin, + makeFakeWritable, + captureWrites, + getContentWrites, +} from "../lifecycle/test-streams.ts"; + +// Whether INK_SCREEN_READER was set in the ambient environment, and to what. +let hadEnv = false; +let savedEnv: string | undefined; + +beforeEach(() => { + hadEnv = Object.prototype.hasOwnProperty.call(process.env, "INK_SCREEN_READER"); + savedEnv = process.env["INK_SCREEN_READER"]; +}); + +afterEach(() => { + // Restore the ORIGINAL state: only re-set the value if it was actually + // present originally; otherwise delete it. Never resurrect/clobber an + // unset-vs-set distinction so an ambient value survives untouched. + if (hadEnv) { + process.env["INK_SCREEN_READER"] = savedEnv; + } else { + delete process.env["INK_SCREEN_READER"]; + } +}); + +test.sequential("INK_SCREEN_READER=true auto-detects SR mode at mount (no explicit option) — linearized output, no border glyphs", async () => { + process.env["INK_SCREEN_READER"] = "true"; + + const App = defineComponent(() => { + return () => ( + + Hello world + + ); + }); + + const app = createApp(App); + const stdout = makeFakeWritable({ columns: 80 }); + const stderr = makeFakeWritable({ columns: 80 }); + const { stream: stdin } = makeFakeStdin(); + const writes = captureWrites(stdout); + + // NO isScreenReaderEnabled option — the env var alone must enable SR mode. + app.mount({ stdout, stdin, stderr, exitOnCtrlC: false }); + + await nextTick(); + await nextTick(); + + const content = getContentWrites(writes).join(""); + // SR-linearized text is present... + expect(content).toContain("Hello world"); + // ...and the 2D box-drawing glyphs are NOT (SR mode linearizes the tree; + // the non-SR path would emit a bordered grid with these glyphs). + for (const glyph of ["╭", "╮", "╰", "╯", "─", "│"]) { + expect(content).not.toContain(glyph); + } + + app.unmount(); +}); + +test.sequential("useIsScreenReaderEnabled() returns true under INK_SCREEN_READER=true auto-detection", async () => { + process.env["INK_SCREEN_READER"] = "true"; + + let observed: boolean | undefined; + const App = defineComponent(() => { + observed = useIsScreenReaderEnabled(); + return () => flag; + }); + + const app = createApp(App); + const stdout = makeFakeWritable({ columns: 80 }); + const stderr = makeFakeWritable({ columns: 80 }); + const { stream: stdin } = makeFakeStdin(); + + app.mount({ stdout, stdin, stderr, exitOnCtrlC: false }); + + await nextTick(); + await nextTick(); + + // The composable observed the env-var-derived SR flag as enabled. + expect(observed).toBe(true); + + app.unmount(); +}); + +test.sequential("explicit isScreenReaderEnabled:false overrides INK_SCREEN_READER=true (?? only falls back when option is undefined)", async () => { + // Guards the `??` semantics: the env var is the FALLBACK, not an override. + // A live app explicitly opting OUT must render the visual (bordered) frame + // even when INK_SCREEN_READER=true. + process.env["INK_SCREEN_READER"] = "true"; + + let observed: boolean | undefined; + const App = defineComponent(() => { + observed = useIsScreenReaderEnabled(); + return () => ( + + Visible + + ); + }); + + const app = createApp(App); + const stdout = makeFakeWritable({ columns: 80 }); + const stderr = makeFakeWritable({ columns: 80 }); + const { stream: stdin } = makeFakeStdin(); + const writes = captureWrites(stdout); + + // Explicit false must win over the env var. + app.mount({ stdout, stdin, stderr, exitOnCtrlC: false, isScreenReaderEnabled: false }); + + await nextTick(); + await nextTick(); + + expect(observed).toBe(false); + const content = getContentWrites(writes).join(""); + expect(content).toContain("Visible"); + // The visual (non-SR) path DOES emit border glyphs. + expect(content).toContain("─"); + + app.unmount(); +}); diff --git a/packages/runtime-tests/integration/composables/use-screen-reader.test.tsx b/packages/runtime-tests/integration/composables/use-screen-reader.test.tsx index 8daa820..49fed5b 100644 --- a/packages/runtime-tests/integration/composables/use-screen-reader.test.tsx +++ b/packages/runtime-tests/integration/composables/use-screen-reader.test.tsx @@ -1,7 +1,11 @@ import { defineComponent } from "vue"; import { expect, test } from "vite-plus/test"; import { render } from "@vue-tui/testing"; -import { Text, useIsScreenReaderEnabled } from "@vue-tui/runtime"; +import { Text, renderToString, useIsScreenReaderEnabled } from "@vue-tui/runtime"; + +// NOTE: tests that auto-detect SR via the process-GLOBAL env var +// `INK_SCREEN_READER` live in use-screen-reader-env.sequential.test.tsx (the +// repo's process-global convention). The tests below never mutate that global. test("useIsScreenReaderEnabled returns false by default", async () => { let result = false; @@ -12,3 +16,19 @@ test("useIsScreenReaderEnabled returns false by default", async () => { await render(App); expect(result).toBe(false); }); + +// B28: the existing test only covers the `false` default. When SR IS enabled, +// useIsScreenReaderEnabled() must return `true`. It reads ctx.isScreenReaderEnabled +// (useIsScreenReaderEnabled.ts:8), which renderToString seeds from the +// isScreenReaderEnabled option (render-to-string.ts:61-64,179). +test("useIsScreenReaderEnabled returns true when SR is enabled (renderToString option)", () => { + let result: boolean | undefined; + const App = defineComponent(() => { + result = useIsScreenReaderEnabled(); + return () => sr enabled; + }); + const output = renderToString(App, { isScreenReaderEnabled: true }); + // Composable observed the enabled flag, and the SR text still rendered. + expect(result).toBe(true); + expect(output).toBe("sr enabled"); +}); diff --git a/packages/runtime-tests/integration/layout/gap.test.tsx b/packages/runtime-tests/integration/layout/gap.test.tsx index c8e7ea7..45dcb12 100644 --- a/packages/runtime-tests/integration/layout/gap.test.tsx +++ b/packages/runtime-tests/integration/layout/gap.test.tsx @@ -1,4 +1,4 @@ -import { defineComponent } from "vue"; +import { defineComponent, nextTick, shallowRef } from "vue"; import { expect, test } from "vite-plus/test"; import { render } from "@vue-tui/testing"; import { Box, Text } from "@vue-tui/runtime"; @@ -43,6 +43,148 @@ test("row gap", async () => { expect(lastFrame({ trimLines: true })).toBe("A\n\nB"); }); +// --- dedicated columnGap / rowGap props (B30, distinct from the `gap` shorthand) --- +// +// Ink semantics (styles.ts applyGapStyles): `columnGap` → yoga GUTTER_COLUMN +// (the gap BETWEEN columns, i.e. horizontal spacing, which is the MAIN-axis gap +// in flexDirection:"row"); `rowGap` → yoga GUTTER_ROW (the gap BETWEEN rows, i.e. +// vertical spacing, the main-axis gap in flexDirection:"column"). vue-tui maps the +// same way (host/yoga.ts: columnGap→GUTTER_COLUMN, rowGap→GUTTER_ROW). +// +// Confirmed against the pinned Ink reference (v7.0.4, /tmp/ink-40b3a75/build, +// renderToString cols=100): +// row + columnGap:2 → "A B" +// column + rowGap:2 → "A\n\n\nB" +// row wrap + columnGap:1 width:3 → "A B\nC" +// row + rowGap:2 → "AB" (cross-axis only; no main-axis effect) +// column + columnGap:2 → "A\nB" (cross-axis only; no main-axis effect) + +test("columnGap is the main-axis (horizontal) gap in flexDirection:row", async () => { + const { lastFrame } = await render( + defineComponent(() => () => ( + + A + B + + )), + { columns: 100 }, + ); + // Two spaces between the columns — matches Ink "A B". + expect(lastFrame({ trimLines: true })).toBe("A B"); +}); + +test("rowGap is the main-axis (vertical) gap in flexDirection:column", async () => { + const { lastFrame } = await render( + defineComponent(() => () => ( + + A + B + + )), + { columns: 100 }, + ); + // Two blank rows between A and B — matches Ink "A\n\n\nB". + expect(lastFrame({ trimLines: true })).toBe("A\n\n\nB"); +}); + +test("columnGap spaces wrapped columns horizontally (row + flexWrap)", async () => { + const { lastFrame } = await render( + defineComponent(() => () => ( + + A + B + C + + )), + { columns: 100 }, + ); + // "A" and "B" share the first row with a 1-col gap; "C" wraps. Matches Ink "A B\nC". + expect(lastFrame({ trimLines: true })).toBe("A B\nC"); +}); + +test("rowGap has no main-axis effect in flexDirection:row (cross-axis only)", async () => { + const { lastFrame } = await render( + defineComponent(() => () => ( + + A + B + + )), + { columns: 100 }, + ); + // rowGap is the BETWEEN-ROWS gap; a single-row layout has no rows to separate, + // so it adds nothing. Matches Ink "AB". + expect(lastFrame({ trimLines: true })).toBe("AB"); +}); + +test("columnGap has no main-axis effect in flexDirection:column (cross-axis only)", async () => { + const { lastFrame } = await render( + defineComponent(() => () => ( + + A + B + + )), + { columns: 100 }, + ); + // columnGap is the BETWEEN-COLUMNS gap; a single-column layout has no columns to + // separate, so it adds nothing. Matches Ink "A\nB". + expect(lastFrame({ trimLines: true })).toBe("A\nB"); +}); + +// Prop-reset: removing columnGap (undefined) or setting it to 0 must collapse the +// horizontal spacing back to the no-gap baseline. yoga.ts resets the gutter to 0 +// when the prop is null/undefined (G19), so the layout must re-flow tight. +test("columnGap resets when removed (undefined) or set to 0", async () => { + const gap = shallowRef(2); + const { lastFrame } = await render( + defineComponent(() => () => ( + + A + B + + )), + { columns: 100 }, + ); + expect(lastFrame({ trimLines: true })).toBe("A B"); + + // Remove the prop entirely → gutter resets to 0, columns abut. + gap.value = undefined; + await nextTick(); + expect(lastFrame({ trimLines: true })).toBe("AB"); + + // Re-apply, then explicitly set 0 → also tight. + gap.value = 3; + await nextTick(); + expect(lastFrame({ trimLines: true })).toBe("A B"); + gap.value = 0; + await nextTick(); + expect(lastFrame({ trimLines: true })).toBe("AB"); +}); + +// Prop-reset for rowGap in the vertical direction. +test("rowGap resets when removed (undefined) or set to 0", async () => { + const gap = shallowRef(2); + const { lastFrame } = await render( + defineComponent(() => () => ( + + A + B + + )), + { columns: 100 }, + ); + expect(lastFrame({ trimLines: true })).toBe("A\n\n\nB"); + + gap.value = undefined; + await nextTick(); + expect(lastFrame({ trimLines: true })).toBe("A\nB"); + + gap.value = 0; + await nextTick(); + expect(lastFrame({ trimLines: true })).toBe("A\nB"); +}); + // Skipped: gap - concurrent // Skipped: column gap - concurrent // Skipped: row gap - concurrent diff --git a/packages/runtime-tests/integration/lifecycle/cursor-commit-path.test.tsx b/packages/runtime-tests/integration/lifecycle/cursor-commit-path.test.tsx index d1ef33e..a1c7f27 100644 --- a/packages/runtime-tests/integration/lifecycle/cursor-commit-path.test.tsx +++ b/packages/runtime-tests/integration/lifecycle/cursor-commit-path.test.tsx @@ -309,6 +309,68 @@ describe("cursor commit-path wiring (interactive stream level)", () => { } }); + test("a child useCursor unmount emits the cursor-HIDE escape (show -> hide ordering)", async () => { + // B19 (Ink parity, use-cursor.ts:29-31): when a `useCursor` child unmounts, + // its onScopeDispose runs ctx.setCursorPosition(undefined) — exactly Ink's + // useInsertionEffect cleanup `context.setCursorPosition(undefined)`. That + // marks log-update cursorDirty with an undefined position. On the NEXT + // commit, the frame changed (child swapped to a no-cursor branch) so render() + // takes the `else` branch and writes buildReturnToBottomPrefix(cursorWasShown: + // true, ...) — which begins with hideCursorEscape (`\x1b[?25l`). So the cursor + // that was SHOWN at the child's position must be HIDDEN when the owner unmounts. + // + // This is observable only at the interactive stream level: the debug render() + // helper has FrameWriter.log === null, so log-update (and its cursor escapes) + // never runs. We capture raw stdout write chunks (Ink's getWriteCalls pattern). + const showChild = shallowRef(true); + const CursorChild = defineComponent(() => { + const { setCursorPosition } = useCursor(); + return () => { + // Distinct cursor column so the SHOW is unambiguous: x=5 -> cursorTo(5). + setCursorPosition({ x: 5, y: 0 }); + return child; + }; + }); + const HostApp = defineComponent(() => { + return () => {showChild.value ? : no cursor here}; + }); + + const { stream: stdout, writes } = makeTtyStdout(); + const stdin = makeTtyStdin(); + + const app = createApp(HostApp); + app.mount({ stdout, stdin, exitOnCtrlC: false, maxFps: 0 }); + await app.waitUntilRenderFlush(); + + // While the child is mounted the cursor is SHOWN at its position (x=5). + const beforeUnmount = writes.join(""); + expect(beforeUnmount).toContain(showCursorEscape); + expect(beforeUnmount).toContain(cursorTo(5)); + // The last visibility change so far is a SHOW (the active cursor). + expect(beforeUnmount.lastIndexOf(showCursorEscape)).toBeGreaterThan( + beforeUnmount.lastIndexOf(hideCursorEscape), + ); + + // Unmount the cursor owner. onScopeDispose -> setCursorPosition(undefined); + // the next commit must HIDE the previously-shown cursor. + const writesBeforeUnmount = writes.length; + showChild.value = false; + await nextTick(); + await app.waitUntilRenderFlush(); + + // A HIDE escape must have been emitted on the unmount commit, and it must be + // the LAST visibility change (the cursor is now gone — not re-shown). + const unmountWrites = writes.slice(writesBeforeUnmount).join(""); + expect(unmountWrites).toContain(hideCursorEscape); + + const fullOutput = writes.join(""); + expect(fullOutput.lastIndexOf(hideCursorEscape)).toBeGreaterThan( + fullOutput.lastIndexOf(showCursorEscape), + ); + + app.unmount(); + }); + test("a synchronous mount throw rethrows the ORIGINAL error even if cursor-restore also throws", async () => { // DEFECT 2b (Codex review): the mount path tears down on a synchronous // throw to re-show the cursor, but teardown's restore (mountedWriter.done() diff --git a/packages/runtime-tests/integration/render-to-string.test.tsx b/packages/runtime-tests/integration/render-to-string.test.tsx index 3f6cdd0..f7837d8 100644 --- a/packages/runtime-tests/integration/render-to-string.test.tsx +++ b/packages/runtime-tests/integration/render-to-string.test.tsx @@ -16,6 +16,11 @@ import { useStdin, useStdout, useStderr, + useCursor, + usePaste, + useTerminalSize, + useAnimation, + useBoxMetrics, } from "@vue-tui/runtime"; describe("renderToString", () => { @@ -637,4 +642,102 @@ describe("renderToString", () => { // "[hi]" glyphs are RAW (no bg SGR); only the trailing Box-fill padding is green. expect(renderToString(App, { columns: 100 })).toBe("[hi]" + chalk.bgGreen(" ")); }); + + // ── B29: renderToString serves the TERMINAL composables with inert no-op + // contexts ────────────────────────────────────────────────────────────── + // + // renderToString runs with NO terminal session: it provides no-op AppContext + + // StdinContext + a no-op AnimationScheduler (render-to-string.ts:93-96). The + // existing suite covers useInput/useApp/useFocus/useFocusManager/useStdin/ + // useStdout/useStderr. These pin the remaining terminal composables — + // useCursor, usePaste, useTerminalSize, useAnimation, useBoxMetrics — so that + // rendering a component which CALLS them degrades to inert values instead of + // throwing (they must still return a string). + describe("terminal composables degrade to no-ops (do not throw)", () => { + test("useCursor does not throw in renderToString", () => { + const App = defineComponent(() => { + // setCursorPosition forwards to the no-op AppContext.setCursorPosition. + const { setCursorPosition } = useCursor(); + setCursorPosition({ x: 2, y: 0 }); + return () => with cursor; + }); + const output = renderToString(App); + expect(output).toBe("with cursor"); + }); + + test("usePaste does not throw in renderToString", () => { + let pasted = ""; + const App = defineComponent(() => { + // usePaste injects StdinContext (no-op here) and attaches to its + // internal_eventEmitter — no terminal session, so the handler never fires. + usePaste((text) => { + pasted = text; + }); + return () => with paste; + }); + const output = renderToString(App); + expect(output).toBe("with paste"); + // The no-op stdin never emits a paste, so the handler stayed inert. + expect(pasted).toBe(""); + }); + + test("useTerminalSize does not throw in renderToString", () => { + const App = defineComponent(() => { + // Resolves dimensions from ctx.stdout (process.stdout in the no-op + // context) with the terminal-size fallback; never throws. + const { columns, rows } = useTerminalSize(); + return () => size {columns.value > 0 && rows.value > 0 ? "ok" : "fallback"}; + }); + const output = renderToString(App); + expect(output).toContain("size"); + }); + + test("useAnimation does not throw in renderToString (frame frozen at 0)", () => { + const App = defineComponent(() => { + // The no-op AnimationScheduler never ticks, so frame stays 0 and no timer + // leaks (subscribe returns an inert unsubscribe). + const { frame } = useAnimation({ interval: 50 }); + return () => {`frame:${frame.value}`}; + }); + const output = renderToString(App); + expect(output).toBe("frame:0"); + }); + + test("useBoxMetrics does not throw in renderToString", () => { + const App = defineComponent(() => { + // useBoxMetrics tracks a Box ref via the root layout listener. In the + // synchronous renderToString teardown the post-flush measurement may not + // have run, so hasMeasured can still be false — the point is it must NOT + // throw and the frame must still render. + const boxRef = shallowRef(null); + const { hasMeasured } = useBoxMetrics(boxRef); + return () => ( + + {hasMeasured.value ? "measured" : "metrics"} + + ); + }); + const output = renderToString(App, { columns: 40 }); + expect(output).toContain("metrics"); + }); + + test("all five terminal composables together render to a string without throwing", () => { + const App = defineComponent(() => { + const { setCursorPosition } = useCursor(); + setCursorPosition({ x: 1, y: 0 }); + usePaste(() => {}); + useTerminalSize(); + const { frame } = useAnimation({ interval: 30 }); + const boxRef = shallowRef(null); + useBoxMetrics(boxRef); + return () => ( + + {`all:${frame.value}`} + + ); + }); + const output = renderToString(App, { columns: 40 }); + expect(output).toContain("all:0"); + }); + }); }); diff --git a/packages/runtime-tests/unit/animation-scheduler.sequential.test.ts b/packages/runtime-tests/unit/animation-scheduler.sequential.test.ts index f6e915b..da12f3c 100644 --- a/packages/runtime-tests/unit/animation-scheduler.sequential.test.ts +++ b/packages/runtime-tests/unit/animation-scheduler.sequential.test.ts @@ -14,6 +14,7 @@ describe.sequential("normalizeInterval", () => { test("clamps and defaults", () => { expect(normalizeInterval(50)).toBe(50); expect(normalizeInterval(0)).toBe(1); + expect(normalizeInterval(-5)).toBe(1); expect(normalizeInterval(-10)).toBe(1); expect(normalizeInterval(undefined)).toBe(100); expect(normalizeInterval(Number.NaN)).toBe(100);