fix(runtime,testing): debug-mode Ink byte-parity + source-hook frame capture (#123)

Two coupled changes, both about debug mode (which @vue-tui/testing's render()
is built on):

1. Debug stdout is now byte-identical to Ink v7.0.4: the debug commit branch
   re-emits the FULL accumulated <Static> history every frame (not just the
   per-commit delta), writes every frame unconditionally (no FrameWriter
   dedup), and drops the synthetic trailing "\n" — matching Ink's
   `fullStaticOutput + output` (ink.tsx:558, output.ts has no trailing newline).

2. The test frame-capture no longer reverse-engineers frames out of stdout.
   The runtime exposes an internal, per-app frame sink (INTERNAL_FRAME_SINK,
   a Symbol from @vue-tui/runtime/internal; the public MountOptions type is
   untouched). The debug branch hands each committed frame to the sink,
   mirroring the stdout writes. @vue-tui/testing's render() builds
   frames[]/lastFrame() from the sink instead of sniffing stdout.

Why: an isTTY:true test stdout (which render() needs for the interactive resize
listener) lets isTTY-gated escapes — bracket-paste \x1b[?2004h/l from usePaste —
land in a stdout-sniffing capture and pollute frames[]. Capturing at the source
makes frames[] provably content-only regardless of which composables a test
mounts, while public debug stdout stays byte-exact to Ink (escapes still written,
not debug-gated). The test surface stays cleanly tiered (Ink's model): render()
= content; createApp+debug:false = in-process control sequences; PTY = real
terminal. '' floor, verbatim SGR/OSC8, frames[] multi-frame/static semantics,
and terminal.resize() are all preserved.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-06-03 00:37:36 +08:00
committed by GitHub
parent 7ace8f735a
commit eaf05466b0
7 changed files with 304 additions and 25 deletions
@@ -338,9 +338,10 @@ test("render only new items in static output on final render", async () => {
const { frames, unmount } = await render(App);
// Initial render — no items, should produce empty or near-empty output
// Initial render — no items: Ink writes `fullStaticOutput + output`, both ""
// here (ink.tsx:558), so the captured initial frame is the empty string.
const initialFrame = frames.at(-1);
expect(initialFrame !== undefined).toBe(true);
expect(initialFrame).toBe("");
items.value = ["A"];
await nextTick();
@@ -679,7 +680,9 @@ test("Static overflows a non-wrapping two-Text row wider than the terminal (Ink
const staticFrame = frames.find((f) => f.includes("ABC") && !f.includes("[live]"));
expect(staticFrame).toBeDefined();
// Ink content-width output is exactly "ABCDEF" (Texts do not shrink/wrap here).
expect(staticFrame).toBe("ABCDEF");
// The captured static chunk is the "\n"-terminated `fullStaticOutput` (each
// static frame is joined as `frame + "\n"`, Ink-faithful — ink.tsx static path).
expect(staticFrame).toBe("ABCDEF\n");
});
// G64 (matches): plain wide TEXT must still WRAP to the terminal width, because
@@ -706,7 +709,8 @@ test("Static wraps a plain wide text to the terminal width (Ink parity, G64)", a
const staticFrame = frames.find((f) => f.includes("ABCDE") && !f.includes("[live]"));
expect(staticFrame).toBeDefined();
expect(staticFrame).toBe("ABCDE\nFGHIJ");
// "\n"-terminated static chunk (`fullStaticOutput`), Ink-faithful.
expect(staticFrame).toBe("ABCDE\nFGHIJ\n");
});
// G64 (matches): a percent-width child wraps against the terminal-width
@@ -742,7 +746,8 @@ test("Static lays out percent-width children against the terminal width (Ink par
const staticFrame = frames.find((f) => f.includes("HAL") && !f.includes("[live]"));
expect(staticFrame).toBeDefined();
expect(staticFrame).toBe("HALEND\nF");
// "\n"-terminated static chunk (`fullStaticOutput`), Ink-faithful.
expect(staticFrame).toBe("HALEND\nF\n");
});
test("Static items do not add blank lines to the dynamic frame", async () => {
@@ -0,0 +1,120 @@
import { defineComponent, nextTick, shallowRef } from "vue";
import { expect, test } from "vite-plus/test";
import { Box, createApp, Static, Text } from "@vue-tui/runtime";
import {
captureWrites,
getContentWrites,
makeFakeStdin,
makeFakeWritable,
} from "./test-streams.ts";
// Debug-mode stdout parity with Ink v7.0.4 (ink.tsx onRender debug branch,
// ~lines 550-558). Ink's debug contract is "every update rendered as a separate,
// FULL output": it writes `this.fullStaticOutput + output` on every render — the
// ENTIRE accumulated <Static> history prepended to the current dynamic frame,
// UNCONDITIONALLY (no equality short-circuit). vue-tui writes the same byte
// stream as two consecutive stdout.write calls (static history, then dynamic
// frame), so these tests assert on the per-render byte stream, not chunk count.
// They pin both halves of the contract: (a) the FULL static history is replayed
// every render (not just the new delta), and (b) a commit that paints a
// byte-identical frame still emits (no FrameWriter dedup).
test("debug mode replays the FULL accumulated <Static> history on each render", async () => {
// Frame 2 (after appending B) must re-print A as well, like Ink's
// `fullStaticOutput + output`, not only the per-commit static delta (B).
const items = shallowRef<string[]>(["A"]);
const App = defineComponent(() => () => (
<Box flexDirection="column">
<Static items={items.value}>
{{ default: ({ item }: { item: string }) => <Text key={item}>{item}</Text> }}
</Static>
<Text>dyn</Text>
</Box>
));
const app = createApp(App);
const stdout = makeFakeWritable({ columns: 80 });
const stderr = makeFakeWritable({ columns: 80 });
const { stream: stdin } = makeFakeStdin();
const writes = captureWrites(stdout);
app.mount({ stdout, stdin, stderr, debug: true, exitOnCtrlC: false });
await nextTick();
await nextTick();
const rawWritesAfterFrame1 = writes.length;
// Append B → a render whose static history is now A AND B.
items.value = ["A", "B"];
await nextTick();
await nextTick();
// Ink writes `fullStaticOutput + output` each render; vue-tui writes that same
// byte stream as (static history) + (dynamic frame). Concatenate the raw
// post-append writes and assert the FULL history (A AND B) is present — i.e. A
// is replayed alongside the new B, not only the delta B.
const frame2Stream = writes.slice(rawWritesAfterFrame1).join("");
expect(frame2Stream).toContain("B"); // sanity: this is the post-append render
expect(frame2Stream).toContain("A"); // Ink replays the FULL static history every render
// And the dynamic frame is still its own chunk (testing-helper frame model).
// Ink writes `fullStaticOutput + output` with NO trailing newline (ink.tsx:558;
// `output` is \n-joined and returned WITHOUT a trailing \n), so the dynamic
// frame chunk is "dyn", not "dyn\n".
expect(getContentWrites(writes)).toContain("dyn");
app.unmount();
});
test("debug mode emits a frame on a commit that produces a byte-identical frame (no dedup)", async () => {
// A re-render that DOES commit (a host mutation occurs — here inserting an
// empty 0x0 <Box> via v-if) but paints a byte-identical frame ("row"). Ink's
// resetAfterCommit fires onRender() on every React commit and writes
// `fullStaticOutput + output` UNCONDITIONALLY, so the identical frame is
// re-emitted. vue-tui must too: pre-fix the frame went through the FrameWriter,
// whose `frame === lastFrame` dedup swallowed this second identical frame.
//
// (Note: a pure reactive tick that mutates NO host node never reaches a commit
// at all in vue-tui — Vue's fine-grained reconciler only fires the renderer's
// onCommit on host mutations, where React runs the commit phase for every
// re-render. That deeper render-vs-commit difference is out of scope for this
// FrameWriter-dedup fix; this test exercises the dedup the audit identified.)
const show = shallowRef(false);
const App = defineComponent(() => () => (
<Box>
<Text>row</Text>
{show.value ? <Box /> : null}
</Box>
));
const app = createApp(App);
const stdout = makeFakeWritable({ columns: 80 });
const stderr = makeFakeWritable({ columns: 80 });
const { stream: stdin } = makeFakeStdin();
const writes = captureWrites(stdout);
app.mount({ stdout, stdin, stderr, debug: true, exitOnCtrlC: false });
await nextTick();
await nextTick();
const framesAfterFirst = getContentWrites(writes).filter((w) => w.includes("row")).length;
expect(framesAfterFirst).toBeGreaterThanOrEqual(1);
// Insert an empty box: a host mutation fires the commit, but the painted frame
// is byte-identical ("row").
show.value = true;
await nextTick();
await nextTick();
const framesAfterSecond = getContentWrites(writes).filter((w) => w.includes("row")).length;
// Ink writes the frame again unconditionally; vue-tui debug mode must emit too.
expect(framesAfterSecond).toBe(framesAfterFirst + 1);
app.unmount();
});
@@ -0,0 +1,36 @@
import { defineComponent } from "vue";
import { expect, test } from "vite-plus/test";
import { Text, usePaste } from "@vue-tui/runtime";
import { render } from "@vue-tui/testing";
// Headline invariant of the B′ "source-hook frame capture" design: the testing
// helper's `frames[]` / `lastFrame()` are CONTENT-ONLY. They are fed each
// committed frame DIRECTLY from the runtime (an internal per-app frame sink),
// NOT reverse-engineered out of stdout. So terminal-control escapes that the
// runtime legitimately writes to stdout (to stay byte-faithful to Ink) must NOT
// leak into `frames[]`.
//
// usePaste() enables bracketed-paste mode, which writes the OSC `\x1b[?2004h`
// enable sequence to stdout (gated on stdout.isTTY, which the testing helper's
// fake stdout is — render.ts setBracketedPasteMode). Under the OLD
// stdout-sniffing capture this escape was pushed into `frames[]` as its own
// frame, polluting the content the test observer sees. Under B′ it goes only to
// stdout; `frames[]` contains the rendered content alone.
test("render() frames are content-only — bracketed-paste escape never leaks into frames", async () => {
const App = defineComponent(() => {
usePaste(() => {});
return () => <Text>content</Text>;
});
const { frames, lastFrame } = await render(App);
// The rendered content is observable.
expect(lastFrame()).toBe("content");
// The bracketed-paste enable escape was written to stdout (byte-faithful to
// Ink) but must NEVER appear in any captured frame.
for (const frame of frames) {
expect(frame).not.toContain("\x1b[?2004h");
expect(frame).not.toContain("\x1b[?2004l");
}
});
+1
View File
@@ -19,3 +19,4 @@ export {
resolveFlags,
type KittyKeyboardController,
} from "./io/kitty-keyboard.ts";
export { INTERNAL_FRAME_SINK, type FrameSink } from "./io/frame-sink.ts";
+32
View File
@@ -0,0 +1,32 @@
/**
* Internal, test-only frame observer.
*
* The `@vue-tui/testing` `render()` helper needs to capture each committed
* frame's CONTENT, with no terminal-control escapes mixed in. Reverse-
* engineering frames out of the stdout byte stream is fragile: stdout must stay
* byte-faithful to Ink (cursor hide/show, bracketed-paste enable/disable, BSU/
* ESU, etc.), so any escape the runtime legitimately writes leaks into the
* captured frames.
*
* Instead, the runtime exposes this per-app frame sink: a callback that the
* commit path invokes with the EXACT content chunks it writes to stdout (the
* accumulated `<Static>` history chunk, then the dynamic frame), in write order
* — but NOT the escapes. The helper passes a sink via a Symbol-keyed mount
* option (so it never appears on the public `MountOptions` type, keeping that
* Ink-faithful) and builds `frames[]` / `lastFrame()` from the callbacks.
*
* The sink is closure-captured per `mount()` call — there is NO module-global
* mutable state — so concurrent test files / multiple apps are fully isolated.
*
* This is intentionally NOT a public API: it lives behind `@vue-tui/runtime/
* internal` and is keyed by a unique symbol the runtime reads off the loosely
* typed mount options.
*/
export type FrameSink = (chunk: string) => void;
/**
* Symbol key for the internal frame sink on the mount options object. Unique
* (created via `Symbol(...)`, not `Symbol.for(...)`) so it can never collide
* with a user-supplied key and is invisible to normal property enumeration.
*/
export const INTERNAL_FRAME_SINK: unique symbol = Symbol("vue-tui.internal.frameSink");
+63 -11
View File
@@ -29,6 +29,7 @@ import { paint } from "./paint/paint.ts";
import { renderScreenReaderOutput } from "./paint/screen-reader.ts";
import { findStatics, paintStaticNode } from "./paint/static-channel.ts";
import { createFrameWriter } from "./io/frame-writer.ts";
import { INTERNAL_FRAME_SINK, type FrameSink } from "./io/frame-sink.ts";
import { bsu, esu, shouldSynchronize } from "./io/write-synchronized.ts";
import {
AppContextKey,
@@ -484,6 +485,15 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
const stderr = options.stderr ?? process.stderr;
const debug = options.debug ?? false;
// Internal, test-only per-app frame sink (see io/frame-sink.ts). Read off a
// Symbol-keyed mount option so it never appears on the public MountOptions
// type (which stays Ink-faithful). Closure-captured here — no module-global
// state — so concurrent test files / multiple apps stay isolated. When set,
// the debug commit branch forwards the EXACT content chunks it writes to
// stdout (static-history chunk, then dynamic frame), MINUS escapes, so the
// testing helper's frames[] are provably content-only.
const frameSink = (options as { [INTERNAL_FRAME_SINK]?: FrameSink })[INTERNAL_FRAME_SINK];
// Instance-reuse guard (Ink parity G14): if a live Vue TUI instance is
// already rendering to this stdout, warn on stderr and skip wiring a second
// competing renderer. The second mount is a deliberate no-op: the caller
@@ -559,7 +569,13 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
// clear()/write/restore on an already-torn-down renderer.
if (teardownStarted) return;
if (debug) {
stdout.write(data + frameState.fullStaticOutput + frameState.lastOutput);
const out = data + frameState.fullStaticOutput + frameState.lastOutput;
stdout.write(out);
// Forward the EXACT stdout bytes to the test-only sink. This is content
// (app data + replayed frame), not a terminal-control escape, so the
// testing helper's frames[] reproduce today's verbatim capture — only
// escapes are excluded under B′.
frameSink?.(out);
return;
}
if (!interactive) {
@@ -582,7 +598,13 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
if (teardownStarted) return;
if (debug) {
stderr.write(data);
stdout.write(frameState.fullStaticOutput + frameState.lastOutput);
const replay = frameState.fullStaticOutput + frameState.lastOutput;
stdout.write(replay);
// Forward the replayed-frame stdout bytes to the test-only sink (content,
// not an escape). The stderr `data` is intentionally NOT forwarded: the
// old verbatim capture wrapped STDOUT only, so stderr never appeared in
// frames[]. Faithful B′ keeps frames[] = the stdout content stream.
frameSink?.(replay);
return;
}
if (!interactive) {
@@ -872,19 +894,49 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp
const outputHeight = frame === "" ? 0 : frame.split("\n").length;
if (debug) {
// Debug mode: write static output directly to stdout, then frame
// through the frame writer (which appends "\n" in debug mode).
// Clear the writer first so the frame is always emitted even when
// the dynamic content is unchanged (static output was written above
// as a separate chunk, so the test harness sees it separately).
if (hasStaticOutput) {
writer.clear();
stdout.write(staticOutput);
// Debug mode mirrors Ink's onRender debug branch (ink.tsx:550-558): its
// contract is "every update rendered as a separate, FULL output". Ink
// writes `this.fullStaticOutput + output` UNCONDITIONALLY on every render
// — the ENTIRE accumulated <Static> history (not just this commit's delta)
// prepended to the current dynamic frame, with NO equality short-circuit.
// Two fixes vs the old behavior, BOTH scoped to this debug branch only:
// (a) re-emit the FULL accumulated history (frameState.fullStaticOutput,
// accumulated at line 847-849) on every render, not just this
// commit's static delta — Ink re-prints all static every render; and
// (b) write straight to stdout, bypassing the FrameWriter, whose
// `frame === lastFrame` dedup would swallow a byte-identical debug
// rerender that Ink still emits.
// The static history and dynamic frame are written as two consecutive
// stdout.write calls: the byte stream reaching the terminal is identical
// to Ink's single `fullStaticOutput + output` write (stdout.write inserts
// no separator), while keeping the dynamic frame its own chunk so the
// @vue-tui/testing render() helper can still split frames (its `lastFrame`
// is the dynamic frame, `frames` distinguishes static from dynamic).
// The non-debug interactive path below is untouched — it keeps the
// per-commit static delta and the FrameWriter dedup (the correct,
// efficient live-render behavior).
if (frameState.fullStaticOutput !== "") {
stdout.write(frameState.fullStaticOutput);
// Forward the static-history chunk to the test-only frame sink in the
// SAME order it reaches stdout (static first, dynamic next), with the
// EXACT value written — so the testing helper's frames[] reproduce
// today's content faithfully, minus the escapes (which only go to
// stdout). No-op when no sink is registered (normal runs).
frameSink?.(frameState.fullStaticOutput);
}
frameState.lastOutput = frame;
frameState.lastOutputToRender = frame;
frameState.outputHeight = outputHeight;
writer.write(frame);
// Ink writes `fullStaticOutput + output` with NO trailing newline
// (ink.tsx:558; `output` is \n-joined and returned WITHOUT a trailing
// \n — output.ts:305-312). Writing `frame` (not `frame + "\n"`) makes
// the concatenation of these two stdout.write calls byte-identical to
// Ink's single write.
stdout.write(frame);
// Always forward the dynamic frame to the sink (mirrors the always-run
// stdout.write above). lastFrame() is the most recent dynamic frame; an
// empty render forwards "" so lastFrame() reads back "" (Ink-faithful).
frameSink?.(frame);
if (onRender) onRender({ renderTime: performance.now() - start });
return;
}
+42 -9
View File
@@ -1,6 +1,7 @@
import { PassThrough } from "node:stream";
import { nextTick, type Component } from "vue";
import { createApp, type TuiApp } from "@vue-tui/runtime";
import { INTERNAL_FRAME_SINK, type FrameSink } from "@vue-tui/runtime/internal";
import { makeFakeStdin, makeFakeWritable, type RawModeState } from "./streams.ts";
import { trackApp } from "./cleanup.ts";
@@ -57,16 +58,45 @@ export async function render(
});
const { stream: stdin, rawMode } = makeFakeStdin();
// Capture committed frames via the runtime's internal per-app frame SINK
// (@vue-tui/runtime/internal: INTERNAL_FRAME_SINK), NOT by reverse-engineering
// them out of stdout. The runtime's debug commit branch invokes this callback
// with the EXACT content chunks it writes to stdout — the accumulated <Static>
// history chunk (when non-empty), then the dynamic frame — in write order, and
// the debug writeToStdout/writeToStderr branches forward their replayed-frame
// bytes too. Terminal-control escapes the runtime writes to stay byte-faithful
// to Ink (bracketed-paste `\x1b[?2004h/l`, cursor hide/show, BSU/ESU) are NOT
// forwarded, so `frames[]` are provably content-only.
//
// Properties this preserves vs the old stdout-sniffing capture:
// - EMPTY render is forwarded as "" (Ink-faithful: `fullStaticOutput +
// output`, both "" — ink.tsx:558), so `frames.at(-1)` reads back "" after
// rendering null and `lastFrame()` correctly returns "".
// - VERBATIM content — NO trailing-newline stripping. The static-history
// chunk stays "\n"-terminated; the dynamic-frame chunk has NO trailing
// newline (output.ts:305-312), so a real blank trailing row (e.g. a height
// 4 box "AB\n\n\n") survives. `trimFrame` / `trimLines` handle display
// trimming in `lastFrame()` instead.
// - The flush WRITE BARRIER (`stdout.write("", () => ...)`) never reaches the
// sink — barriers are pure stdout drain awaits, not commits — so they can't
// clobber `lastFrame()`.
const frames: string[] = [];
stdout.on("data", (chunk) => {
let raw = chunk.toString();
// Debug-mode frame writer appends "\n"; strip it so frame height matches yoga layout
if (raw.endsWith("\n")) raw = raw.slice(0, -1);
frames.push(raw);
});
const frameSink: FrameSink = (chunk) => {
frames.push(chunk);
};
const app: TuiApp = createApp(component, options.props ?? undefined);
app.mount({ stdout, stdin, stderr, debug: true, exitOnCtrlC: options.exitOnCtrlC ?? false });
// The frame sink is passed via a Symbol-keyed INTERNAL option, kept off the
// public MountOptions type (Ink-faithful). Cast through `Parameters` to attach
// it without widening the public type.
app.mount({
stdout,
stdin,
stderr,
debug: true,
exitOnCtrlC: options.exitOnCtrlC ?? false,
[INTERNAL_FRAME_SINK]: frameSink,
} as Parameters<TuiApp["mount"]>[0]);
trackApp(app);
@@ -113,8 +143,11 @@ export async function render(
return {
lastFrame: (opts?: LastFrameOptions) => {
const f = frames.at(-1);
if (f === undefined) return undefined;
// An empty render is written (and so captured) as "", so `frames.at(-1)`
// reads back "" after rendering null — matching Ink. `?? ""` is a defensive
// floor for the (unreachable in practice) pre-first-render read; `render()`
// always flushes at least one render before returning.
const f = frames.at(-1) ?? "";
if (opts?.raw) return f;
if (opts?.trimLines)
return f