feat(testing): test harness for @vue-tui/runtime

Provides render() for integration testing — async with auto-flush,
trimmed frames, fake terminal with resize(), stdin.write() with
auto-flush, and auto-cleanup via afterEach.

Exports: render, cleanup, RenderResult, RenderOptions, Terminal

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yunfei He
2026-05-24 18:13:47 +08:00
parent ff82f6e064
commit 71c517b79f
10 changed files with 327 additions and 0 deletions
+35
View File
@@ -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.
+37
View File
@@ -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"
}
}
+19
View File
@@ -0,0 +1,19 @@
/// <reference types="vite-plus/test/globals" />
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());
}
+3
View File
@@ -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";
+97
View File
@@ -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<string, unknown>;
exitOnCtrlC?: boolean;
}
export interface Terminal {
readonly columns: number;
readonly rows: number;
resize(columns: number, rows: number): Promise<void>;
rawMode: RawModeState;
}
export interface RenderResult {
lastFrame(this: void): string | undefined;
frames: string[];
stdin: {
write(data: string): Promise<void>;
};
terminal: Terminal;
unmount(this: void): void;
waitUntilExit(this: void): Promise<void>;
}
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<RenderResult> {
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<void> {
stdin.emit("data", data);
await nextTick();
},
},
terminal,
unmount: app.unmount.bind(app),
waitUntilExit: app.waitUntilExit.bind(app),
};
}
+38
View File
@@ -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 };
}
+39
View File
@@ -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(() => <Text>hello</Text>);
expect(lastFrame()).toContain("hello");
});
test("frames accumulate on reactive updates", async () => {
const message = ref("first");
const { lastFrame, frames } = await render(() => <Text>{message.value}</Text>);
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(() => <Text>hello</Text>, { columns: 20 });
expect(lastFrame()).toContain("hello");
});
test("lastFrame trims trailing whitespace", async () => {
const { lastFrame } = await render(() => <Text>hi</Text>);
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(() => <Text>auto</Text>);
expect(lastFrame()).toContain("auto");
});
+23
View File
@@ -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");
});
+22
View File
@@ -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"
}
}
+14
View File
@@ -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: {},
});