diff --git a/packages/runtime-tests/integration/components/static.test.tsx b/packages/runtime-tests/integration/components/static.test.tsx index eb72be9..78d46e0 100644 --- a/packages/runtime-tests/integration/components/static.test.tsx +++ b/packages/runtime-tests/integration/components/static.test.tsx @@ -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 () => { diff --git a/packages/runtime-tests/integration/lifecycle/debug-mode-output.test.tsx b/packages/runtime-tests/integration/lifecycle/debug-mode-output.test.tsx new file mode 100644 index 0000000..72fa1f3 --- /dev/null +++ b/packages/runtime-tests/integration/lifecycle/debug-mode-output.test.tsx @@ -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 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 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(["A"]); + + const App = defineComponent(() => () => ( + + + {{ default: ({ item }: { item: string }) => {item} }} + + dyn + + )); + + 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 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(() => () => ( + + row + {show.value ? : null} + + )); + + 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(); +}); diff --git a/packages/runtime-tests/integration/lifecycle/render-frames-content-only.test.tsx b/packages/runtime-tests/integration/lifecycle/render-frames-content-only.test.tsx new file mode 100644 index 0000000..6320a18 --- /dev/null +++ b/packages/runtime-tests/integration/lifecycle/render-frames-content-only.test.tsx @@ -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 () => content; + }); + + 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"); + } +}); diff --git a/packages/runtime/src/internal.ts b/packages/runtime/src/internal.ts index b1bf21e..b368915 100644 --- a/packages/runtime/src/internal.ts +++ b/packages/runtime/src/internal.ts @@ -19,3 +19,4 @@ export { resolveFlags, type KittyKeyboardController, } from "./io/kitty-keyboard.ts"; +export { INTERNAL_FRAME_SINK, type FrameSink } from "./io/frame-sink.ts"; diff --git a/packages/runtime/src/io/frame-sink.ts b/packages/runtime/src/io/frame-sink.ts new file mode 100644 index 0000000..d79c156 --- /dev/null +++ b/packages/runtime/src/io/frame-sink.ts @@ -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 `` 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"); diff --git a/packages/runtime/src/render.ts b/packages/runtime/src/render.ts index e104eb4..de3c754 100644 --- a/packages/runtime/src/render.ts +++ b/packages/runtime/src/render.ts @@ -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 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; } diff --git a/packages/testing/src/render.ts b/packages/testing/src/render.ts index dbbae76..1052367 100644 --- a/packages/testing/src/render.ts +++ b/packages/testing/src/render.ts @@ -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 + // 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[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