eaf05466b0
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>
121 lines
5.0 KiB
TypeScript
121 lines
5.0 KiB
TypeScript
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();
|
|
});
|