Files
vue-tui/packages/testing
Yunfei He eaf05466b0 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>
2026-06-03 00:37:36 +08:00
..

@vue-tui/testing

Early stage — under active development. Bug reports welcome, but not recommended for production use yet.

Test harness for vue-tui — render Vue 3 terminal components, simulate input, assert frames. Like @testing-library, but for the terminal.

npm version npm downloads

Why

  • Isolated terminal — renders into a fake TTY, no real terminal needed
  • Input simulation — inject keystrokes that reach useInput handlers
  • Frame snapshots — assert exact visual output with lastFrame() and frames[]
  • Auto-cleanup — unmounts all rendered apps after each test (requires Vitest globals: true)

Install

Assumes @vue-tui/runtime and vue are already installed in your project.

npm install -D @vue-tui/testing

Quick Start

import { defineComponent, shallowRef } from "vue";
import { expect, test } from "vitest";
import { render } from "@vue-tui/testing";
import { Box, Text, useInput } from "@vue-tui/runtime";

test("counter responds to + and - keys", async () => {
  const Counter = defineComponent(() => {
    const count = shallowRef(0);
    useInput((input) => {
      if (input === "+") count.value++;
      if (input === "-") count.value--;
    });
    return () => (
      <Box>
        <Text>Count: {count.value}</Text>
      </Box>
    );
  });

  const { lastFrame, stdin } = await render(Counter);
  expect(lastFrame()).toContain("Count: 0");

  await stdin.write("+");
  expect(lastFrame()).toContain("Count: 1");

  await stdin.write("-");
  expect(lastFrame()).toContain("Count: 0");
});

API

render(component, options?)

Mounts a component in a fake terminal environment. Returns a RenderResult.

Option Type Default Description
columns number 100 Terminal width in columns
rows number 100 Terminal height in rows
props object — Props passed to the root component
exitOnCtrlC boolean false Enable Ctrl+C exit handling

RenderResult

Property / Method Description
lastFrame(opts?) Latest rendered frame as a string
frames Array of all captured frame snapshots
stdin.write(data) Inject input (reaches useInput handlers)
terminal Fake terminal — columns, rows, resize(), rawMode
unmount() Tear down the app
waitUntilExit() Settles when the app exits (rejects if exit(error))
waitUntilRenderFlush() Resolves after the next render cycle completes

cleanup()

Unmounts all rendered apps. Auto-registered as a Vitest afterEach hook when globals: true is set. Call manually if your test runner doesn't expose a global afterEach.

License

MIT