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:
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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: {},
|
||||
});
|
||||
Reference in New Issue
Block a user