// Stream-level cursor parity (Ink test/cursor.tsx:89-193). The existing // use-cursor.test.tsx tests pass for the WRONG reason — they go through the // debug render() helper (debug:true => FrameWriter.log is null, so log-update // never runs) and only assert a local capturedX + lastFrame().toContain. These // tests mount a REAL interactive TTY (isTTY:true, debug:false) so log-update // actually composes the cursor escapes, then capture the raw stdout write // chunks (like Ink's getWriteCalls) and assert the real ANSI cursor sequence. // // ansiEscapes.cursorTo(x) === `\x1b[${x+1}G`, so: // useCursor x=2 -> cursorTo(2) -> "\x1b[3G" // after typing 'a' x=3 -> cursorTo(3) -> "\x1b[4G" // after a space x=4 -> cursorTo(4) -> "\x1b[5G" import { PassThrough } from "node:stream"; import { defineComponent, h, nextTick, shallowRef } from "vue"; import { describe, expect, test } from "vite-plus/test"; import { Box, Text, createApp, useCursor, useInput, useStdout } from "@vue-tui/runtime"; const showCursorEscape = "\x1b[?25h"; const hideCursorEscape = "\x1b[?25l"; // Synchronized-update markers (BSU/ESU): begin/end the "?2026" private mode. const bsu = "\x1b[?2026h"; const esu = "\x1b[?2026l"; // ansiEscapes.cursorTo(x) is a 1-based column move: `\x1b[${x + 1}G`. const cursorTo = (x: number) => `\x1b[${x + 1}G`; function makeTtyStdout(): { stream: NodeJS.WriteStream; writes: string[] } { const stream = new PassThrough() as unknown as NodeJS.WriteStream; Object.assign(stream, { isTTY: true, columns: 100, rows: 100 }); // Capture EVERY write() call (like Ink's getWriteCalls). The cursor escapes // and synchronized-update wrappers are separate write() calls, so an // on("data") listener that coalesces chunks would still see them — but // wrapping write directly mirrors Ink's sinon spy exactly and lets us count // calls for the "writes increased" assertion. const writes: string[] = []; const original = stream.write.bind(stream); stream.write = ((...args: unknown[]) => { writes.push(String(args[0])); return (original as (...a: unknown[]) => boolean)(...args); }) as NodeJS.WriteStream["write"]; return { stream, writes }; } function makeTtyStdin(): NodeJS.ReadStream { const s = new PassThrough() as unknown as NodeJS.ReadStream; Object.assign(s, { isTTY: true, setRawMode(this: NodeJS.ReadStream) { return this; }, setEncoding(this: NodeJS.ReadStream) { return this; }, }); (s as unknown as { ref: () => void }).ref = () => {}; (s as unknown as { unref: () => void }).unref = () => {}; return s; } // Mirrors Ink's InputApp (test/cursor.tsx:65-87): cursor at x = 2 + text.length. const InputApp = 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 () => { setCursorPosition({ x: 2 + text.value.length, y: 0 }); return ( {`> ${text.value}`} ); }; }); describe("cursor commit-path wiring (interactive stream level)", () => { test("cursor is shown at the useCursor position after first render", async () => { const { stream: stdout, writes } = makeTtyStdout(); const stdin = makeTtyStdin(); // maxFps:0 makes commits immediate (no ~34ms throttle), so the first frame // is flushed synchronously through log-update. const app = createApp(InputApp); app.mount({ stdout, stdin, exitOnCtrlC: false, maxFps: 0 }); await app.waitUntilRenderFlush(); const output = writes.join(""); expect(output).toContain(showCursorEscape); // x=2 -> cursorTo(2) -> "\x1b[3G" expect(output).toContain(cursorTo(2)); app.unmount(); }); test("last cursor visibility change after first render is SHOW, not HIDE", async () => { // Ink test/cursor.tsx:113-134. A later commit must not re-hide the cursor // that log-update showed. const { stream: stdout, writes } = makeTtyStdout(); const stdin = makeTtyStdin(); const app = createApp(InputApp); app.mount({ stdout, stdin, exitOnCtrlC: false, maxFps: 0 }); await app.waitUntilRenderFlush(); const output = writes.join(""); expect(output.lastIndexOf(showCursorEscape)).toBeGreaterThan( output.lastIndexOf(hideCursorEscape), ); app.unmount(); }); test("cursor follows text input (cursorTo(3) after typing 'a')", async () => { const { stream: stdout, writes } = makeTtyStdout(); const stdin = makeTtyStdin(); const app = createApp(InputApp); app.mount({ stdout, stdin, exitOnCtrlC: false, maxFps: 0 }); await app.waitUntilRenderFlush(); stdin.emit("data", "a"); await app.waitUntilRenderFlush(); const output = writes.join(""); expect(output).toContain(showCursorEscape); // After 'a', x=3 -> cursorTo(3) -> "\x1b[4G" expect(output).toContain(cursorTo(3)); app.unmount(); }); test("cursor moves on a space keystroke even when the frame is byte-identical", async () => { // Ink test/cursor.tsx:159-193. A space appends to the text so the cursor // moves, but the rendered frame string can be byte-identical to the previous // one (trailing space gets collapsed/trimmed in layout). Ink still writes a // cursor-only sequence (buildCursorOnlySequence) gated on isCursorDirty(), // so write count must INCREASE and cursorTo(4) must appear. const { stream: stdout, writes } = makeTtyStdout(); const stdin = makeTtyStdin(); const app = createApp(InputApp); app.mount({ stdout, stdin, exitOnCtrlC: false, maxFps: 0 }); await app.waitUntilRenderFlush(); stdin.emit("data", "a"); await app.waitUntilRenderFlush(); const writeCountAfterA = writes.length; stdin.emit("data", " "); await app.waitUntilRenderFlush(); expect(writes.length).toBeGreaterThan(writeCountAfterA); const output = writes.join(""); // After "a ", x=4 -> cursorTo(4) -> "\x1b[5G" expect(output).toContain(cursorTo(4)); app.unmount(); }); test("a useStdout().write() does not leave the cursor hidden", async () => { // After an external stdout write, restoreLastOutput re-shows the cursor; // a subsequent commit must not re-hide it. So the LAST show index must be // after the LAST hide index (Ink's invariant for an active cursor). let writeFromHook: ((data: string) => void) | undefined; const StdoutWriteApp = defineComponent(() => { const { setCursorPosition } = useCursor(); const { write } = useStdout(); writeFromHook = write; return () => { // Set the cursor every render so it stays active across commits. setCursorPosition({ x: 2, y: 0 }); return Hello; }; }); const { stream: stdout, writes } = makeTtyStdout(); const stdin = makeTtyStdin(); const app = createApp(StdoutWriteApp); app.mount({ stdout, stdin, exitOnCtrlC: false, maxFps: 0 }); await app.waitUntilRenderFlush(); // External write -> clear() + data + restoreLastOutput() (which re-shows // the cursor). A trailing commit must NOT re-hide it. writeFromHook?.("from stdout hook\n"); await app.waitUntilRenderFlush(); const output = writes.join(""); expect(output).toContain(showCursorEscape); expect(output.lastIndexOf(showCursorEscape)).toBeGreaterThan( output.lastIndexOf(hideCursorEscape), ); app.unmount(); }); test("an idle cursor-dirty re-render emits NO empty BSU/ESU pair", async () => { // DEFECT 1 (Ink fidelity, ink.tsx:372-382 + 1094). When a render marks the // cursor dirty (a fresh position object each render) but BOTH the position // value AND the output are unchanged, willRender() is false. Ink's inner // BSU/ESU gate is `willRender()` ALONE, so it emits ZERO bytes — it does not // wrap a no-op log-update write in a synchronized-update pair. The gate must // therefore NOT emit `\x1b[?2026h` immediately followed by `\x1b[?2026l` // with nothing between (an empty sync-update pair). // // Repro note: Vue's static-VNode optimization suppresses a second commit // when nothing in the tree changes, so we force one with a `key` bump that // remounts the child (remove+insert -> onCommit) while the rendered text // stays byte-identical. patchConsole:false keeps the render path clean. const { stream: stdout, writes } = makeTtyStdout(); const stdin = makeTtyStdin(); let bumpKey: (() => void) | undefined; const KeyBumpApp = defineComponent(() => { const { setCursorPosition } = useCursor(); const tick = shallowRef(0); bumpKey = () => { tick.value++; }; return () => { // Fresh position object every render at the SAME x/y: marks cursorDirty // but the position VALUE is unchanged from the previous render. setCursorPosition({ x: 2, y: 0 }); // Bumping `key` remounts this Text (forces a commit) but the text is // byte-identical, so the rendered frame does not change. return {h(Text, { key: tick.value }, () => "Hello")}; }; }); const app = createApp(KeyBumpApp); app.mount({ stdout, stdin, exitOnCtrlC: false, maxFps: 0, patchConsole: false }); await app.waitUntilRenderFlush(); // Only inspect the SECOND (idle) commit's writes — the first commit // legitimately emits a BSU/ESU pair around the initial frame. const writesBeforeIdle = writes.length; bumpKey?.(); await nextTick(); await app.waitUntilRenderFlush(); const idleWrites = writes.slice(writesBeforeIdle); // No BSU immediately followed by ESU (an empty synchronized-update pair). const hasEmptySyncPair = idleWrites.some( (chunk, i) => chunk === bsu && idleWrites[i + 1] === esu, ); expect(hasEmptySyncPair).toBe(false); // And no BSU leaks at all on this no-op frame (Ink emits zero bytes here). expect(idleWrites).not.toContain(bsu); app.unmount(); }); test("a synchronous mount-time throw does not leave the cursor hidden", async () => { // DEFECT 2 (regression). The cursor is hidden BEFORE originalMount so the // first commit's SHOW is the last visibility change. But if originalMount // throws SYNCHRONOUSLY in a way onErrorCaptured cannot catch (a renderer/ // patch-level vnode error — here a vnode whose `type` getter throws), the // teardown handlers were registered only AFTER originalMount, so nothing // would ever re-show the cursor -> terminal left permanently invisible. // Ink wires signalExit(this.unmount) in its constructor (ink.tsx:426), // before any hide; we get the same guarantee by tearing down (which shows // the cursor) on a synchronous mount throw before rethrowing. const { stream: stdout, writes } = makeTtyStdout(); const stdin = makeTtyStdin(); const ThrowOnPatchApp = defineComponent(() => { return () => { // A vnode whose `type` getter throws during the renderer's patch phase. // This bypasses the onErrorCaptured boundary (it is a renderer-level // error, not a child component render error). const vnode = h("div"); Object.defineProperty(vnode, "type", { get() { throw new Error("boom from vnode type getter"); }, }); return vnode as never; }; }); const app = createApp(ThrowOnPatchApp); let mountThrew = false; try { app.mount({ stdout, stdin, exitOnCtrlC: false, maxFps: 0 }); } catch { mountThrew = true; } // The mount must have actually thrown (otherwise the repro is invalid). expect(mountThrew).toBe(true); const output = writes.join(""); // The cursor was hidden on mount; the terminal must not be left hidden. // Either no hide leaked, or a SHOW follows the last HIDE. if (output.includes(hideCursorEscape)) { expect(output).toContain(showCursorEscape); expect(output.lastIndexOf(showCursorEscape)).toBeGreaterThan( output.lastIndexOf(hideCursorEscape), ); } }); 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() // -> log-update showCursor -> stdout.write("\x1b[?25h")) can ITSELF throw // (e.g. stdout.write fails). If that restore error escapes teardown() it // REPLACES the original mount error, masking the real failure. The contract: // a synchronous mount throw must ALWAYS rethrow the ORIGINAL error, even // when the best-effort cursor/screen restore also fails. const { stream: stdout } = makeTtyStdout(); const stdin = makeTtyStdin(); // Make the show-cursor restore write throw. We wrap write so that the very // act of restoring the cursor (the "\x1b[?25h" escape teardown emits) fails, // standing in for a real stdout whose write() throws during restore. const restoreError = new Error("stdout.write blew up during cursor restore"); const originalWrite = stdout.write.bind(stdout); stdout.write = ((...args: unknown[]) => { if (String(args[0]).includes(showCursorEscape)) { throw restoreError; } return (originalWrite as (...a: unknown[]) => boolean)(...args); }) as NodeJS.WriteStream["write"]; // The root ALSO throws synchronously during mount (a renderer/patch-level // vnode error that bypasses onErrorCaptured), with a distinctive message. const ThrowOnPatchApp = defineComponent(() => { return () => { const vnode = h("div"); Object.defineProperty(vnode, "type", { get() { throw new Error("boom from vnode type getter"); }, }); return vnode as never; }; }); const app = createApp(ThrowOnPatchApp); let caught: unknown; try { app.mount({ stdout, stdin, exitOnCtrlC: false, maxFps: 0 }); } catch (err) { caught = err; } // The error propagated out of mount must be the ORIGINAL mount error, not // the cursor-restore error that the best-effort teardown raised. expect(caught).toBeInstanceOf(Error); expect((caught as Error).message).toBe("boom from vnode type getter"); expect((caught as Error).message).not.toBe(restoreError.message); }); });