fix(runtime): feed useCursor position to the interactive commit path (Ink parity) (#80)
On the interactive commit path the active cursor position was never forwarded to the frame writer, so the cursor was never shown at the useCursor() position, never followed input, and a cursor-only move on a byte-identical frame emitted nothing. Aligns with Ink v7.0.4 by wiring three coupled defects together: - render.ts setCursorPosition now forwards to writer.setCursorPosition, marking log-update's cursorDirty (Ink ink.tsx:494-497). - the synchronized-update commit gate is split into Ink's two levels: the write is gated on willRender() || isCursorDirty(), but BSU/ESU wrap only when willRender() (Ink ink.tsx:1094 outer, :372-382 inner) -- an idle cursor-dirty re-render emits zero bytes, not an empty BSU/ESU pair. - FrameWriter.write() bypasses its frame===lastFrame dedup when the cursor is dirty, so a cursor-only move still reaches log-update's buildCursorOnlySequence. The mount-time hide-cursor write moves before originalMount so the first commit's show is the last visibility change (Ink hides before its first render); a synchronous mount throw now runs best-effort teardown (cursor/alt-screen restore) before rethrowing the ORIGINAL error, matching Ink's constructor-wired signalExit. Adds 8 interactive-TTY tests; the prior use-cursor tests used the debug render() helper where log-update never runs, so they passed for the wrong reason. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,363 @@
|
||||
// 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 (
|
||||
<Box>
|
||||
<Text>{`> ${text.value}`}</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
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 <Text>Hello</Text>;
|
||||
};
|
||||
});
|
||||
|
||||
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 <Box>{h(Text, { key: tick.value }, () => "Hello")}</Box>;
|
||||
};
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user