tuix
Version:
A performant TUI framework for Bun with JSX and reactive state management
231 lines (201 loc) • 6.29 kB
text/typescript
/**
* Testing Input Adapter - Programmatic key simulation for testing TUI apps
*/
import { Effect, Layer, Queue, Stream, Ref, Option } from "effect"
import { InputService } from "@/services/input.ts"
import type { KeyEvent, MouseEvent } from "@/core/types.ts"
import { KeyType } from "@/core/keys.ts"
/**
* Test input adapter that allows programmatic key injection
*/
export class TestInputAdapter {
private keyQueue: Queue.Queue<KeyEvent>
private mouseQueue: Queue.Queue<MouseEvent>
private running = false
constructor(
keyQueue: Queue.Queue<KeyEvent>,
mouseQueue: Queue.Queue<MouseEvent>
) {
this.keyQueue = keyQueue
this.mouseQueue = mouseQueue
}
/**
* Simulate a key press
*/
pressKey(key: string | KeyEvent): Promise<void> {
const keyEvent: KeyEvent = typeof key === 'string'
? this.createKeyEvent(key)
: key
return Effect.runPromise(Queue.offer(this.keyQueue, keyEvent))
}
/**
* Simulate multiple key presses with delays
*/
async pressKeys(keys: (string | KeyEvent)[], delayMs = 100): Promise<void> {
for (const key of keys) {
await this.pressKey(key)
if (delayMs > 0) {
await new Promise(resolve => setTimeout(resolve, delayMs))
}
}
}
/**
* Type a string (converts to individual key events)
*/
async type(text: string, delayMs = 50): Promise<void> {
const keys = text.split('').map(char => this.createKeyEvent(char))
await this.pressKeys(keys, delayMs)
}
/**
* Simulate special keys
*/
async pressSpecialKey(key: 'enter' | 'escape' | 'up' | 'down' | 'left' | 'right' | 'tab' | 'space'): Promise<void> {
await this.pressKey(this.createSpecialKeyEvent(key))
}
/**
* Simulate Ctrl+key combination
*/
async pressCtrl(key: string): Promise<void> {
await this.pressKey({
type: key === 'c' ? KeyType.CtrlC : KeyType.Runes,
key: `ctrl+${key}`,
runes: undefined,
ctrl: true,
alt: false,
shift: false,
meta: false
})
}
/**
* Create a basic key event from a string
*/
private createKeyEvent(key: string): KeyEvent {
return {
type: KeyType.Runes,
key: key,
runes: key,
ctrl: false,
alt: false,
shift: false,
meta: false
}
}
/**
* Create special key events
*/
private createSpecialKeyEvent(key: string): KeyEvent {
const specialKeys: Record<string, KeyType> = {
enter: KeyType.Enter,
escape: KeyType.Escape,
up: KeyType.Up,
down: KeyType.Down,
left: KeyType.Left,
right: KeyType.Right,
tab: KeyType.Tab,
space: KeyType.Space
}
return {
type: specialKeys[key] || KeyType.Runes,
key: key,
runes: key === 'space' ? ' ' : undefined,
ctrl: false,
alt: false,
shift: false,
meta: false
}
}
}
/**
* Create a test input service with programmatic control
*/
export const createTestInputService = (): Layer.Layer<InputService, never, never> => {
return Layer.effect(
InputService,
Effect.gen(function* (_) {
const keyQueue = yield* _(Queue.unbounded<KeyEvent>())
const mouseQueue = yield* _(Queue.unbounded<MouseEvent>())
// Create the adapter for external control
const adapter = new TestInputAdapter(keyQueue, mouseQueue)
// Store adapter globally for test access
;(globalThis as any).__testInputAdapter = adapter
return {
keyEvents: Stream.fromQueue(keyQueue),
mouseEvents: Stream.fromQueue(mouseQueue),
allEvents: Stream.merge(
Stream.fromQueue(keyQueue).pipe(
Stream.map(key => ({ _tag: 'key' as const, event: key }))
),
Stream.fromQueue(mouseQueue).pipe(
Stream.map(mouse => ({ _tag: 'mouse' as const, event: mouse }))
)
),
waitForKey: Queue.take(keyQueue),
waitForMouse: Queue.take(mouseQueue),
clearInputBuffer: Effect.gen(function* (_) {
yield* _(Queue.takeAll(keyQueue))
yield* _(Queue.takeAll(mouseQueue))
}),
filterKeys: (predicate) =>
Stream.fromQueue(keyQueue).pipe(
Stream.filter(predicate)
),
mapKeys: (mapper) =>
Stream.fromQueue(keyQueue).pipe(
Stream.filterMap((key) => {
const result = mapper(key)
return result !== null ? Option.some(result) : Option.none()
})
),
debounceKeys: (ms) =>
Stream.fromQueue(keyQueue).pipe(
Stream.debounce(ms)
),
parseAnsiSequence: (sequence) => Effect.succeed(null),
rawInput: Stream.empty,
setEcho: (enabled) => Effect.sync(() => {})
}
})
)
}
/**
* Get the global test input adapter (for use in tests)
*/
export const getTestInputAdapter = (): TestInputAdapter | null => {
return (globalThis as any).__testInputAdapter || null
}
/**
* Helper to run a TUI app with test input
*/
export const runTestApp = async <Model, Msg>(
component: any, // Component type
testScript: (adapter: TestInputAdapter) => Promise<void>,
config?: any
): Promise<void> => {
const { runApp } = await import("@/core/runtime.ts")
const { TerminalServiceLive } = await import("@/services/impl/terminal-impl.ts")
const { RendererServiceLive } = await import("@/services/impl/renderer-impl.ts")
const { StorageServiceLive } = await import("@/services/impl/storage-impl.ts")
const { Layer } = await import("effect")
// Create test services with test input
const TestServices = Layer.mergeAll([
TerminalServiceLive,
createTestInputService(),
RendererServiceLive,
StorageServiceLive
])
// Start the app
const appPromise = Effect.runPromise(
runApp(component, config).pipe(
Effect.provide(TestServices)
)
)
// Wait a bit for app to start
await new Promise(resolve => setTimeout(resolve, 100))
// Get the adapter and run the test script
const adapter = getTestInputAdapter()
if (adapter) {
await testScript(adapter)
}
// Let the app run a bit more
await new Promise(resolve => setTimeout(resolve, 500))
}