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>
This commit is contained in:
Yunfei He
2026-06-05 13:31:35 +08:00
committed by GitHub
parent ea5ef18325
commit 7707322382
4 changed files with 164 additions and 9 deletions
@@ -31,3 +31,19 @@ it("debug after exit", async () => {
expect(clean).toContain("Hello");
expect(clean).toContain("DONE");
});
// Byte-level Ink parity for the DEBUG + non-interactive (CI=true) unmount stream.
// Ink (ink.tsx @ v7.0.4): the debug onRender writes `fullStaticOutput + output`
// per render with NO trailing newline (550-558); at unmount settleThrottle leaves
// shouldRenderFinalFrame=true for debug (throttledOnRender is undefined → 749-762),
// so a final onRender RE-EMITS the last frame; then finishUnmount writes a single
// trailing "\n" for the non-interactive+debug case (812-819). For the single
// static-free "Hello" render (fullStaticOutput="" , lastOutput="Hello") the exact
// stdout byte stream is "Hello" + "Hello" + "\n". The PTY translates the lone "\n"
// to "\r\n", so the raw capture must end with exactly "HelloHello\r\n".
// The ci-debug-bytes fixture uses a function slot so no Vue dev warning is
// interleaved — the raw stream is exactly the debug frames.
it("debug mode in CI re-emits the final frame and writes a trailing newline (Ink byte parity)", async () => {
const output = await run("ci-debug-bytes", { env: { CI: "true" }, columns: 0 });
expect(output).toBe("HelloHello\r\n");
});
@@ -0,0 +1,10 @@
import { createApp, Text } from "@vue-tui/runtime";
import { defineComponent, h } from "vue";
// Function-slot form avoids Vue's dev-mode "Non-function value encountered for
// default slot" warning, so the captured byte stream is exactly the debug
// frames vue-tui writes (no warning noise interleaved). Renders "Hello" once.
const App = defineComponent(() => () => h(Text, () => "Hello"));
const app = createApp(App);
app.mount({ debug: true });