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: {}, +});