All skills
msmps avatar

/opentui

@0d61d6d
by Matt Simpsonmsmps/opentui-skill225 stars
9

OpenTUI skill for building terminal user interfaces with the Core, React, or Solid APIs. Use for any TUI task including components, layout, keyboard and keymap handling, animations, and testing.

Use this Skill: https://skilld.dev/gh/msmps/opentui-skill/opentui

This session only. Nothing lands on disk.

referencestestingREFERENCE.md

≈3.7k tokens on demand. Your agent reads this file only when SKILL.md points to it.

Testing OpenTUI Applications

How to test terminal user interfaces built with OpenTUI.

Overview

OpenTUI provides:

  • Test Renderer: Headless renderer for testing
  • Snapshot Testing: Verify visual output
  • Interaction Testing: Simulate user input

When to Use

Use this reference when you need snapshot tests, interaction testing, or renderer-based regression checks.

Test Setup

Bun Test Runner

OpenTUI uses Bun's built-in test runner:

import { test, expect, beforeEach, afterEach } from "bun:test"

Test Renderer

Create a test renderer for headless testing:

import { createTestRenderer } from "@opentui/core/testing"

const testSetup = await createTestRenderer({
  width: 80,     // Terminal width
  height: 24,    // Terminal height
})

Core Testing

Basic Test

import { test, expect } from "bun:test"
import { createTestRenderer } from "@opentui/core/testing"
import { TextRenderable } from "@opentui/core"

test("renders text", async () => {
  const testSetup = await createTestRenderer({
    width: 40,
    height: 10,
  })
  
  const text = new TextRenderable(testSetup.renderer, {
    id: "greeting",
    content: "Hello, World!",
  })
  
  testSetup.renderer.root.add(text)
  await testSetup.renderOnce()
  
  expect(testSetup.captureCharFrame()).toContain("Hello, World!")
})

Snapshot Testing

import { test, expect, afterEach } from "bun:test"
import { createTestRenderer } from "@opentui/core/testing"
import { BoxRenderable, TextRenderable } from "@opentui/core"

let testSetup: Awaited<ReturnType<typeof createTestRenderer>>

afterEach(() => {
  if (testSetup) {
    testSetup.renderer.destroy()
  }
})

test("component matches snapshot", async () => {
  testSetup = await createTestRenderer({
    width: 40,
    height: 10,
  })
  
  const box = new BoxRenderable(testSetup.renderer, {
    id: "box",
    border: true,
    width: 20,
    height: 5,
  })
  box.add(new TextRenderable(testSetup.renderer, {
    content: "Content",
  }))
  
  testSetup.renderer.root.add(box)
  await testSetup.renderOnce()
  
  expect(testSetup.captureCharFrame()).toMatchSnapshot()
})

Test Renderer Setup Object

createTestRenderer() returns an expanded TestRendererSetup with helpers for driving frames, input, and asserting on output:

Property Type Description
renderer TestRenderer Headless renderer instance
mockInput MockInput Simulate keyboard input
mockMouse MockMouse Simulate mouse events
renderOnce() () => Promise<void> Trigger a single render cycle
flush(options?) (o?: { maxPasses? }) => Promise<void> Drain scheduled render work (default 20 passes)
waitFor(predicate, options?) Promise<void> Advance frames until a boolean/async predicate passes
waitForFrame(predicate, options?) Promise<string> Like waitFor, predicate receives the frame string; resolves to it
waitForVisualIdle(options?) (o?: { quietFrames?, maxFrames? }) => Promise<void> Wait until rendering settles (defaults 1 / 20)
captureCharFrame() () => string Capture output as text
captureSpans() () => CapturedFrame Capture styled spans
externalOutput TestExternalOutput Capture scrollback / external output (take(), takeText(), clear())
getNativeStats() () => NativeRenderStats Native render statistics
resize(width, height) (w, h) => void Resize the virtual terminal
import { createTestRenderer } from "@opentui/core/testing"

const t = await createTestRenderer({ width: 40, height: 10 })

// Wait for async content instead of guessing at renderOnce() timing
await t.waitForFrame((frame) => frame.includes("Loaded"))

// Or wait until the UI stops changing
await t.waitForVisualIdle()

// Assert on scrollback written above the viewport
const commits = t.externalOutput.take()

TestRendererOptions extends CliRendererConfig with width, height, kittyKeyboard, and otherModifiersMode. Other exports from @opentui/core/testing: createMockKeys, KeyCodes, createMockMouse, MouseButtons, createSpy, TestRecorder, ManualClock, createTerminalCapabilities, setRendererCapabilities, MockTreeSitterClient.

React Testing

Test Utilities

React provides a built-in testRender utility via the @opentui/react/test-utils subpath export:

import { testRender } from "@opentui/react/test-utils"

This utility:

  • Creates a headless test renderer
  • Sets up the React Act environment automatically
  • Handles proper unmounting on destroy
  • Returns the standard test setup object

Basic Component Test

import { test, expect } from "bun:test"
import { testRender } from "@opentui/react/test-utils"

function Greeting({ name }: { name: string }) {
  return <text>Hello, {name}!</text>
}

test("Greeting renders name", async () => {
  const testSetup = await testRender(
    <Greeting name="World" />,
    { width: 80, height: 24 }
  )
  
  await testSetup.renderOnce()
  const frame = testSetup.captureCharFrame()
  
  expect(frame).toContain("Hello, World!")
})

Snapshot Testing

import { test, expect, afterEach } from "bun:test"
import { testRender } from "@opentui/react/test-utils"

let testSetup: Awaited<ReturnType<typeof testRender>>

afterEach(() => {
  if (testSetup) {
    testSetup.renderer.destroy()
  }
})

test("component matches snapshot", async () => {
  testSetup = await testRender(
    <box style={{ width: 20, height: 5, border: true }}>
      <text>Content</text>
    </box>,
    { width: 25, height: 8 }
  )
  
  await testSetup.renderOnce()
  const frame = testSetup.captureCharFrame()
  
  expect(frame).toMatchSnapshot()
})

State Testing

import { test, expect, afterEach } from "bun:test"
import { useState } from "react"
import { testRender } from "@opentui/react/test-utils"

let testSetup: Awaited<ReturnType<typeof testRender>>

afterEach(() => {
  if (testSetup) {
    testSetup.renderer.destroy()
  }
})

function Counter() {
  const [count, setCount] = useState(0)
  return (
    <box>
      <text>Count: {count}</text>
    </box>
  )
}

test("Counter shows initial value", async () => {
  testSetup = await testRender(
    <Counter />,
    { width: 20, height: 5 }
  )
  
  await testSetup.renderOnce()
  const frame = testSetup.captureCharFrame()
  
  expect(frame).toContain("Count: 0")
})

Test Setup/Teardown Pattern

For multiple tests, use beforeEach/afterEach to manage the renderer lifecycle:

import { describe, test, expect, beforeEach, afterEach } from "bun:test"
import { testRender } from "@opentui/react/test-utils"

let testSetup: Awaited<ReturnType<typeof testRender>>

describe("MyComponent", () => {
  beforeEach(async () => {
    if (testSetup) {
      testSetup.renderer.destroy()
    }
  })

  afterEach(() => {
    if (testSetup) {
      testSetup.renderer.destroy()
    }
  })

  test("renders correctly", async () => {
    testSetup = await testRender(<MyComponent />, {
      width: 40,
      height: 10,
    })

    await testSetup.renderOnce()
    const frame = testSetup.captureCharFrame()
    expect(frame).toMatchSnapshot()
  })
})

Test Setup Return Object

The testRender function returns a test setup object with these properties:

Property Type Description
renderer Renderer The headless renderer instance
renderOnce () => Promise<void> Triggers a single render cycle
captureCharFrame () => string Captures current output as text
resize (width, height) => void Resize the virtual terminal

Solid Testing

Test Utilities

Solid exports testRender directly from the main package:

import { testRender } from "@opentui/solid"

Note: Unlike React, Solid's testRender takes a function component (not a JSX element).

Basic Component Test

import { test, expect } from "bun:test"
import { testRender } from "@opentui/solid"

function Greeting(props: { name: string }) {
  return <text>Hello, {props.name}!</text>
}

test("Greeting renders name", async () => {
  const testSetup = await testRender(
    () => <Greeting name="World" />,
    { width: 80, height: 24 }
  )
  
  await testSetup.renderOnce()
  const frame = testSetup.captureCharFrame()
  
  expect(frame).toContain("Hello, World!")
})

Snapshot Testing

import { test, expect, afterEach } from "bun:test"
import { testRender } from "@opentui/solid"

let testSetup: Awaited<ReturnType<typeof testRender>>

afterEach(() => {
  if (testSetup) {
    testSetup.renderer.destroy()
  }
})

test("component matches snapshot", async () => {
  testSetup = await testRender(
    () => (
      <box style={{ width: 20, height: 5, border: true }}>
        <text>Content</text>
      </box>
    ),
    { width: 25, height: 8 }
  )
  
  await testSetup.renderOnce()
  const frame = testSetup.captureCharFrame()
  
  expect(frame).toMatchSnapshot()
})

Snapshot Format

Snapshots capture the rendered terminal output as text:

┌──────────────────┐
│ Hello, World!    │
│                  │
└──────────────────┘

Updating Snapshots

bun test --update-snapshots

Interaction Testing

Simulating Key Presses

import { test, expect, afterEach } from "bun:test"
import { createTestRenderer } from "@opentui/core/testing"

let testSetup: Awaited<ReturnType<typeof createTestRenderer>>

afterEach(() => {
  if (testSetup) {
    testSetup.renderer.destroy()
  }
})

test("responds to keyboard", async () => {
  testSetup = await createTestRenderer({
    width: 40,
    height: 10,
  })
  
  // Create component that responds to keys
  // ...
  
  // Simulate keypress
  testSetup.renderer.keyInput.emit("keypress", {
    name: "enter",
    sequence: "\r",
    ctrl: false,
    shift: false,
    meta: false,
    option: false,
    eventType: "press",
    repeated: false,
  })
  
  // Render after the keypress
  await testSetup.renderOnce()
  
  expect(testSetup.captureCharFrame()).toContain("Selected")
})

Testing Focus

import { test, expect, afterEach } from "bun:test"
import { createTestRenderer } from "@opentui/core/testing"
import { InputRenderable } from "@opentui/core"

let testSetup: Awaited<ReturnType<typeof createTestRenderer>>

afterEach(() => {
  if (testSetup) {
    testSetup.renderer.destroy()
  }
})

test("input receives focus", async () => {
  testSetup = await createTestRenderer({
    width: 40,
    height: 10,
  })
  
  const input = new InputRenderable(testSetup.renderer, {
    id: "test-input",
    placeholder: "Type here",
  })
  testSetup.renderer.root.add(input)
  
  input.focus()
  
  expect(input.focused).toBe(true)
})

Test Organization

File Structure

src/
├── components/
│   ├── Button.tsx
│   └── Button.test.tsx
├── hooks/
│   ├── useCounter.ts
│   └── useCounter.test.ts
└── test-utils.tsx

Running Tests

# Run all tests
bun test

# Run specific test file
bun test src/components/Button.test.tsx

# Run with filter
bun test --test-name-pattern "Button"

# Watch mode
bun test --watch

Patterns

Testing Conditional Rendering (React)

import { test, expect, afterEach } from "bun:test"
import { testRender } from "@opentui/react/test-utils"

let testSetup: Awaited<ReturnType<typeof testRender>>

afterEach(() => {
  if (testSetup) {
    testSetup.renderer.destroy()
  }
})

test("shows loading state", async () => {
  testSetup = await testRender(
    <DataLoader loading={true} />,
    { width: 40, height: 10 }
  )
  
  await testSetup.renderOnce()
  expect(testSetup.captureCharFrame()).toContain("Loading...")
})

test("shows data when loaded", async () => {
  testSetup = await testRender(
    <DataLoader loading={false} data={["Item 1", "Item 2"]} />,
    { width: 40, height: 10 }
  )
  
  await testSetup.renderOnce()
  const frame = testSetup.captureCharFrame()
  expect(frame).toContain("Item 1")
  expect(frame).toContain("Item 2")
})

Testing Lists

test("renders all items", async () => {
  const items = ["Apple", "Banana", "Cherry"]
  
  testSetup = await testRender(
    <ItemList items={items} />,
    { width: 40, height: 10 }
  )
  
  await testSetup.renderOnce()
  const frame = testSetup.captureCharFrame()
  
  items.forEach(item => {
    expect(frame).toContain(item)
  })
})

Testing Layouts

test("matches layout snapshot", async () => {
  testSetup = await testRender(
    <AppLayout />,
    { width: 120, height: 40 }  // Larger viewport
  )
  
  await testSetup.renderOnce()
  expect(testSetup.captureCharFrame()).toMatchSnapshot()
})

Debugging Tests

Print Frame Output

import { testRender } from "@opentui/react/test-utils"

test("debug output", async () => {
  const testSetup = await testRender(
    <MyComponent />,
    { width: 40, height: 10 }
  )
  
  await testSetup.renderOnce()
  const frame = testSetup.captureCharFrame()
  
  // Print to see what's rendered
  console.log(frame)
  
  expect(frame).toContain("expected")
})

Verbose Mode

bun test --verbose

Gotchas

Async Rendering

Always call renderOnce() after setting up your component to ensure rendering is complete:

const testSetup = await testRender(<MyComponent />, { width: 40, height: 10 })
await testSetup.renderOnce()  // Required before capturing frame
const frame = testSetup.captureCharFrame()

Test Isolation and Cleanup

Always destroy the renderer after each test to avoid resource leaks:

import { afterEach } from "bun:test"

let testSetup: Awaited<ReturnType<typeof testRender>>

afterEach(() => {
  if (testSetup) {
    testSetup.renderer.destroy()
  }
})

test("test 1", async () => {
  testSetup = await testRender(<Component1 />, { width: 40, height: 10 })
  // ...
})

test("test 2", async () => {
  testSetup = await testRender(<Component2 />, { width: 40, height: 10 })
  // ...
})

Snapshot Dimensions

Be consistent with test dimensions for stable snapshots:

const testSetup = await createTestRenderer({
  width: 80,   // Standard width
  height: 24,  // Standard height
})

Running from Package Directory

Run tests from the package directory:

cd packages/core
bun test

# Not from repo root for package-specific tests

See Also

Source: SKILL.md on GitHub

No alerts17d5 checks · Risk SAFE
  • Gen Agent Trust Hub17d

    This skill contains documentation and usage patterns for the OpenTUI framework, designed for building terminal user interfaces across Core, React, and SolidJS. The analysis confirmed that the skill provides legitimate development guidance; no malicious code, persistence mechanisms, or obfuscation were detected. Findings are limited to standard platform capabilities and recommended scaffolding tools.

  • Socket17d

    No alerts

  • Snyk17d

    Risk: LOW · No issues

  • Runlayer7mo

    1/26 files flagged

  • ZeroLeaks5mo

    Score: 93/100 · 2 sections analyzed

Signed by skilld at 0d61d6d. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub last month.

Steadyupdated last month
Other metadata
metadata
{
  "references": "core, react, solid, components, layout, keyboard, keymap, animation, testing"
}
  • React
  • Testing
  • terminal-ui
  • tui
  • solid
  • components
  • keyboard-input
  • animations
  • layout

README badge

README badge for msmps/opentui-skill

Builds terminal user interfaces with OpenTUI using an imperative core API or React/Solid reconcilers. Covers layout, keyboard handling, animations, components, and testing for full-featured TUI applications.

Generated from the current SKILL.md.

Does this skill cover all three OpenTUI frameworks?
Yes. The skill consolidates the core imperative API, React reconciler, and Solid reconciler. Use the decision tree in the skill to pick the right framework for your task.
How do I start a new OpenTUI project?
Use `create-tui` with options before arguments: `bunx create-tui -t react my-app` (not `bunx create-tui my-app -t react`). Then read the REFERENCE.md for your chosen framework.
What runtime does OpenTUI require?
OpenTUI runs on Bun and uses Zig for native builds. See `./references/core/gotchas.md` for runtime requirements and build guidance.
Can I use `process.exit()` to shut down my TUI?
No. Always use `renderer.destroy()` instead. Calling `process.exit()` directly is a common pitfall covered in `core/gotchas.md`.
How do I apply text styling in React or Solid?
Use nested modifier elements, not props. See `components/text-display.md` for the correct pattern.

Generated from the current SKILL.md. These answers refresh after source changes.