Files
vue-tui/packages/testing/tests/frames-teardown.test.tsx
T
Yunfei He 7707322382 fix(runtime): align debug-mode unmount byte stream to Ink (#142)
* fix(runtime): align debug-mode unmount byte stream to Ink

In DEBUG mode (non-interactive) vue-tui's teardown emitted neither a final-frame
re-emit nor a trailing newline, while Ink emits both (ink.tsx:749-762 settleThrottle
re-emit + ink.tsx:812-819 `debug ? '\n' : lastOutput + '\n'`). So for a debug app
that renders "Hello" once, Ink's byte stream is "HelloHello\n" but vue-tui's was
just "Hello" — a divergence that matters when porting Ink debug snapshots / CI logs.

- Fire the final-frame re-emit `mountedCommit()` for debug too (was interactive-only).
- In the non-interactive teardown write, emit a bare "\n" for debug (Ink parity),
  keeping the non-debug `lastFrame + "\n"` branch byte-identical.

Because the @vue-tui/testing render() helper captures debug commits via an internal
frame sink, gate the debug commit's two `frameSink?.(...)` forwards on
`!teardownStarted` so the teardown re-emit (a stdout byte-parity FLUSH, not a render)
does not append a spurious entry to the helper's live `frames[]`. `teardownStarted`
is set at the top of teardown() before the re-emit, so this covers EVERY teardown
route (unmount / cleanup / exit / Ctrl+C / signal / process.exit). Both `stdout.write`
calls stay unconditional, preserving byte parity.

Adds a PTY byte-parity test (asserts "HelloHello\r\n") and a testing-helper test
covering all teardown routes (frames.length stable, incl. <Static>).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: drop CI-fragile exitOnCtrlC frames-teardown case

The Ctrl+C case timed out in CI (waitUntilExit never resolved — stdin/raw-mode
timing is environment-fragile), while passing locally. Ctrl+C routes through the
SAME exit-driven teardown path as programmatic useApp().exit()
(emitInput → appContext.exit() → teardown()), which the remaining exit() cases
already cover, so removing it loses no teardown-route coverage of the
!teardownStarted frame-sink gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:31:35 +08:00

102 lines
3.9 KiB
TypeScript

import { defineComponent } from "vue";
import { expect, test } from "vite-plus/test";
import { render } from "../src/index.ts";
import { Static, Text, useApp } from "@vue-tui/runtime";
// The runtime's DEBUG-mode teardown re-emits the final frame to stdout for Ink
// byte-parity (packages/runtime/src/render.ts: the final mountedCommit() in
// teardown()). Because render() registers an internal frame SINK that pushes
// every debug commit chunk into the live `frames` array, that teardown re-emit
// would append spurious entries to `frames` at teardown time.
//
// `frames` must represent renders DURING the test, not the cleanup-time flush.
// The runtime gates the debug commit's SINK calls on `!teardownStarted`, so the
// teardown re-emit still WRITES to stdout (byte parity) but does NOT push to the
// sink. teardownStarted is set TRUE at the top of teardown() before the re-emit,
// so this covers EVERY teardown route — manual unmount() and the exit-driven
// routes — not just the helper's own unmount. We exercise manual unmount() and
// programmatic useApp().exit(); exitOnCtrlC Ctrl+C is the SAME exit-driven path
// (emitInput → appContext.exit() → teardown()), so the exit() cases below cover
// it (a Ctrl+C-specific case is omitted — it depends on stdin/raw-mode timing
// that is environment-fragile in CI, and adds no teardown-route coverage).
test("manual unmount() does not append a teardown frame", async () => {
const { frames, unmount } = await render(() => <Text>hello</Text>);
const before = frames.length;
expect(before).toBeGreaterThan(0);
unmount();
// The teardown re-emit (final mountedCommit) writes the dynamic frame again;
// without the runtime gate it would push one more entry here.
expect(frames.length).toBe(before);
});
test("programmatic useApp().exit() does not append a teardown frame", async () => {
let doExit: (() => void) | undefined;
const Comp = defineComponent(() => {
const { exit } = useApp();
doExit = () => exit();
return () => <Text>bye</Text>;
});
const { frames, waitUntilExit } = await render(Comp);
const before = frames.length;
expect(before).toBeGreaterThan(0);
// exit() routes through teardown() WITHOUT the helper's unmount wrapper.
doExit?.();
await waitUntilExit();
expect(frames.length).toBe(before);
});
test("lastFrame() still reads the last real render after unmount", async () => {
const { frames, lastFrame, unmount } = await render(() => <Text>hi</Text>);
const lastBefore = frames.at(-1);
expect(lastFrame()).toContain("hi");
unmount();
// The last captured frame must still be the final real render, not a
// teardown-appended duplicate.
expect(frames.at(-1)).toBe(lastBefore);
expect(lastFrame()).toContain("hi");
});
test("unmount() with <Static> does not append static + dynamic teardown frames", async () => {
const { frames, unmount } = await render(() => (
<Static items={["a", "b"]}>{({ item }: { item: string }) => <Text>{item}</Text>}</Static>
));
const before = frames.length;
expect(before).toBeGreaterThan(0);
unmount();
// With <Static> present the teardown re-emit writes TWO chunks (accumulated
// static history, then the dynamic frame). Without the gate `frames` grows by
// +2; with the gate neither chunk reaches the sink.
expect(frames.length).toBe(before);
});
test("useApp().exit() with <Static> does not append static + dynamic teardown frames", async () => {
let doExit: (() => void) | undefined;
const Comp = defineComponent(() => {
const { exit } = useApp();
doExit = () => exit();
return () => (
<Static items={["a", "b"]}>{({ item }: { item: string }) => <Text>{item}</Text>}</Static>
);
});
const { frames, waitUntilExit } = await render(Comp);
const before = frames.length;
expect(before).toBeGreaterThan(0);
doExit?.();
await waitUntilExit();
// Exit-driven <Static> teardown must also not push the static + dynamic pair.
expect(frames.length).toBe(before);
});