diff --git a/packages/testing/README.md b/packages/testing/README.md
new file mode 100644
index 0000000..7a3229e
--- /dev/null
+++ b/packages/testing/README.md
@@ -0,0 +1,35 @@
+# @vue-tui/testing
+
+Test harness for [`@vue-tui/runtime`](../runtime).
+
+## Install
+
+```bash
+pnpm add -D @vue-tui/testing vue
+```
+
+## Quickstart
+
+```ts
+import { defineComponent, h } from "vue";
+import { test, expect } from "vitest";
+import { render, flush } from "@vue-tui/testing";
+import { Text } from "@vue-tui/runtime";
+
+test("renders hello", async () => {
+ const App = defineComponent({ render: () => h(Text, null, "hello") });
+ const r = render(App);
+ await flush();
+ expect(r.lastFrame()).toContain("hello");
+ r.unmount();
+});
+```
+
+## API
+
+- **`render(app, { columns?, rows? })`** — mount in a fake-TTY environment, collect frames.
+- **`flush()`** — await Vue's post-flush queue + Node's immediate queue.
+- **`result.lastFrame()` / `result.frames`** — frame snapshots.
+- **`result.stdin.write(data)`** — inject input that reaches `useInput` handlers.
+- **`result.app`** — the underlying [`TuiApp`](../runtime/README.md#tuiapp-interface) (use `.waitUntilExit()` if needed).
+- **`result.unmount()` / `result.waitUntilExit()`** — convenience pass-throughs.
diff --git a/packages/testing/package.json b/packages/testing/package.json
new file mode 100644
index 0000000..8300723
--- /dev/null
+++ b/packages/testing/package.json
@@ -0,0 +1,37 @@
+{
+ "name": "@vue-tui/testing",
+ "version": "0.0.0",
+ "description": "Test harness for @vue-tui/runtime.",
+ "license": "MIT",
+ "files": [
+ "dist"
+ ],
+ "type": "module",
+ "exports": {
+ ".": "./dist/index.mjs",
+ "./package.json": "./package.json"
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "scripts": {
+ "build": "vp pack",
+ "dev": "vp pack --watch",
+ "test": "vp test",
+ "check": "vp check",
+ "prepublishOnly": "vp run build"
+ },
+ "dependencies": {
+ "@vue-tui/runtime": "workspace:*"
+ },
+ "devDependencies": {
+ "@types/node": "^25.6.2",
+ "@vitejs/plugin-vue-jsx": "catalog:",
+ "typescript": "^6.0.3",
+ "vite-plus": "^0.1.20",
+ "vue": "^3.4.0"
+ },
+ "peerDependencies": {
+ "vue": "^3.4.0"
+ }
+}
diff --git a/packages/testing/src/cleanup.ts b/packages/testing/src/cleanup.ts
new file mode 100644
index 0000000..740b06b
--- /dev/null
+++ b/packages/testing/src/cleanup.ts
@@ -0,0 +1,19 @@
+///
+import type { TuiApp } from "@vue-tui/runtime";
+
+const activeApps: TuiApp[] = [];
+
+export function trackApp(app: TuiApp): void {
+ activeApps.push(app);
+}
+
+export function cleanup(): void {
+ for (const app of activeApps) {
+ app.unmount();
+ }
+ activeApps.length = 0;
+}
+
+if (typeof afterEach === "function") {
+ afterEach(() => cleanup());
+}
diff --git a/packages/testing/src/index.ts b/packages/testing/src/index.ts
new file mode 100644
index 0000000..7fafa3e
--- /dev/null
+++ b/packages/testing/src/index.ts
@@ -0,0 +1,3 @@
+export { render, type RenderOptions, type RenderResult, type Terminal } from "./render.ts";
+export { type RawModeState } from "./streams.ts";
+export { cleanup } from "./cleanup.ts";
diff --git a/packages/testing/src/render.ts b/packages/testing/src/render.ts
new file mode 100644
index 0000000..818fbd7
--- /dev/null
+++ b/packages/testing/src/render.ts
@@ -0,0 +1,97 @@
+import { PassThrough } from "node:stream";
+import { nextTick, type Component } from "vue";
+import { createApp, type TuiApp } from "@vue-tui/runtime";
+import { makeFakeStdin, makeFakeWritable, type RawModeState } from "./streams.ts";
+import { trackApp } from "./cleanup.ts";
+
+export interface RenderOptions {
+ columns?: number;
+ rows?: number;
+ props?: Record;
+ exitOnCtrlC?: boolean;
+}
+
+export interface Terminal {
+ readonly columns: number;
+ readonly rows: number;
+ resize(columns: number, rows: number): Promise;
+ rawMode: RawModeState;
+}
+
+export interface RenderResult {
+ lastFrame(this: void): string | undefined;
+ frames: string[];
+ stdin: {
+ write(data: string): Promise;
+ };
+ terminal: Terminal;
+ unmount(this: void): void;
+ waitUntilExit(this: void): Promise;
+}
+
+function trimFrame(raw: string): string {
+ return raw
+ .split("\n")
+ .map((line) => line.trimEnd())
+ .join("\n")
+ .trimEnd();
+}
+
+export async function render(
+ component: Component,
+ options: RenderOptions = {},
+): Promise {
+ const stdout = makeFakeWritable({
+ columns: options.columns ?? 100,
+ rows: options.rows ?? 100,
+ });
+ const stderr = makeFakeWritable({
+ columns: options.columns ?? 100,
+ rows: options.rows ?? 100,
+ });
+ const { stream: stdin, rawMode } = makeFakeStdin();
+
+ const frames: string[] = [];
+ stdout.on("data", (chunk) => {
+ frames.push(trimFrame(chunk.toString()));
+ });
+
+ const app: TuiApp = createApp(component, options.props ?? undefined);
+ app.mount({ stdout, stdin, stderr, debug: true, exitOnCtrlC: options.exitOnCtrlC ?? false });
+
+ trackApp(app);
+
+ await nextTick();
+
+ const terminal: Terminal = {
+ get columns() {
+ return stdout.columns;
+ },
+ get rows() {
+ return stdout.rows;
+ },
+ async resize(columns: number, rows: number) {
+ stdout.columns = columns;
+ stdout.rows = rows;
+ (stderr as NodeJS.WriteStream).columns = columns;
+ (stderr as NodeJS.WriteStream).rows = rows;
+ (stdout as unknown as PassThrough).emit("resize");
+ await nextTick();
+ },
+ rawMode,
+ };
+
+ return {
+ lastFrame: () => frames.at(-1),
+ frames,
+ stdin: {
+ async write(data: string): Promise {
+ stdin.emit("data", data);
+ await nextTick();
+ },
+ },
+ terminal,
+ unmount: app.unmount.bind(app),
+ waitUntilExit: app.waitUntilExit.bind(app),
+ };
+}
diff --git a/packages/testing/src/streams.ts b/packages/testing/src/streams.ts
new file mode 100644
index 0000000..3f8c64b
--- /dev/null
+++ b/packages/testing/src/streams.ts
@@ -0,0 +1,38 @@
+import { PassThrough } from "node:stream";
+
+export interface FakeWritableOptions {
+ columns?: number;
+ rows?: number;
+}
+
+export function makeFakeWritable(options: FakeWritableOptions = {}): NodeJS.WriteStream {
+ const s = new PassThrough() as unknown as NodeJS.WriteStream;
+ Object.assign(s, {
+ columns: options.columns ?? 100,
+ rows: options.rows ?? 100,
+ isTTY: true,
+ });
+ return s;
+}
+
+export interface RawModeState {
+ readonly current: boolean;
+ readonly history: readonly boolean[];
+}
+
+export function makeFakeStdin(): { stream: NodeJS.ReadStream; rawMode: RawModeState } {
+ const rawMode = { current: false, history: [] as boolean[] };
+ const s = new PassThrough() as unknown as NodeJS.ReadStream;
+ Object.assign(s, {
+ isTTY: true,
+ setRawMode(this: NodeJS.ReadStream, mode: boolean) {
+ rawMode.current = mode;
+ (rawMode.history as boolean[]).push(mode);
+ return this;
+ },
+ setEncoding(this: NodeJS.ReadStream) {
+ return this;
+ },
+ });
+ return { stream: s, rawMode };
+}
diff --git a/packages/testing/tests/render.test.tsx b/packages/testing/tests/render.test.tsx
new file mode 100644
index 0000000..53f006d
--- /dev/null
+++ b/packages/testing/tests/render.test.tsx
@@ -0,0 +1,39 @@
+import { nextTick, ref } from "vue";
+import { expect, test } from "vite-plus/test";
+import { render } from "../src/index.ts";
+import { Text } from "@vue-tui/runtime";
+
+test("lastFrame captures rendered output", async () => {
+ const { lastFrame } = await render(() => hello);
+ expect(lastFrame()).toContain("hello");
+});
+
+test("frames accumulate on reactive updates", async () => {
+ const message = ref("first");
+ const { lastFrame, frames } = await render(() => {message.value});
+ const initialCount = frames.length;
+ expect(lastFrame()).toContain("first");
+
+ message.value = "second";
+ await nextTick();
+ expect(lastFrame()).toContain("second");
+ expect(frames.length).toBeGreaterThan(initialCount);
+});
+
+test("render with custom columns", async () => {
+ const { lastFrame } = await render(() => hello, { columns: 20 });
+ expect(lastFrame()).toContain("hello");
+});
+
+test("lastFrame trims trailing whitespace", async () => {
+ const { lastFrame } = await render(() => hi);
+ const frame = lastFrame()!;
+ for (const line of frame.split("\n")) {
+ expect(line).toBe(line.trimEnd());
+ }
+});
+
+test("auto cleanup — no manual unmount needed", async () => {
+ const { lastFrame } = await render(() => auto);
+ expect(lastFrame()).toContain("auto");
+});
diff --git a/packages/testing/tests/streams.test.ts b/packages/testing/tests/streams.test.ts
new file mode 100644
index 0000000..759f1d8
--- /dev/null
+++ b/packages/testing/tests/streams.test.ts
@@ -0,0 +1,23 @@
+import { expect, test } from "vite-plus/test";
+import { makeFakeStdin, makeFakeWritable } from "../src/streams.ts";
+
+test("fake stdout reports columns/rows and isTTY", () => {
+ const s = makeFakeWritable({ columns: 50, rows: 10 });
+ expect(s.columns).toBe(50);
+ expect(s.rows).toBe(10);
+ expect(s.isTTY).toBe(true);
+});
+
+test("fake stdin supports setRawMode and emits data", () => {
+ const { stream: s, rawMode } = makeFakeStdin();
+ expect(s.isTTY).toBe(true);
+ let observed = "";
+ s.on("data", (c) => {
+ observed += c.toString();
+ });
+ s.setRawMode(true);
+ expect(rawMode.current).toBe(true);
+ expect(rawMode.history).toEqual([true]);
+ s.emit("data", "hello");
+ expect(observed).toBe("hello");
+});
diff --git a/packages/testing/tsconfig.json b/packages/testing/tsconfig.json
new file mode 100644
index 0000000..5baeccb
--- /dev/null
+++ b/packages/testing/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "compilerOptions": {
+ "target": "esnext",
+ "lib": ["es2023"],
+ "moduleDetection": "force",
+ "module": "nodenext",
+ "moduleResolution": "nodenext",
+ "resolveJsonModule": true,
+ "types": ["node"],
+ "strict": true,
+ "noUnusedLocals": true,
+ "declaration": true,
+ "noEmit": true,
+ "allowImportingTsExtensions": true,
+ "esModuleInterop": true,
+ "isolatedModules": true,
+ "verbatimModuleSyntax": true,
+ "skipLibCheck": true,
+ "jsx": "preserve",
+ "jsxImportSource": "vue"
+ }
+}
diff --git a/packages/testing/vite.config.ts b/packages/testing/vite.config.ts
new file mode 100644
index 0000000..bbd9ff3
--- /dev/null
+++ b/packages/testing/vite.config.ts
@@ -0,0 +1,14 @@
+import { defineConfig } from "vite-plus";
+import vueJsx from "@vitejs/plugin-vue-jsx";
+
+export default defineConfig({
+ plugins: [vueJsx()],
+ pack: {
+ dts: { tsgo: true },
+ exports: true,
+ },
+ lint: {
+ options: { typeAware: true, typeCheck: true },
+ },
+ fmt: {},
+});