UNPKG

tuix

Version:

A performant TUI framework for Bun with JSX and reactive state management

150 lines (124 loc) 4.59 kB
/** * Simple Test Harness for CLI-Kit Applications * * A simpler version that uses child_process instead of PTY */ import { Effect, Ref } from "effect" import { spawn, type ChildProcess } from "child_process" import { writeFileSync, mkdirSync } from "fs" import { join } from "path" export interface SimpleHarnessOptions { readonly command: string readonly args?: string[] readonly env?: Record<string, string> readonly cwd?: string readonly screenshotDir?: string } export interface SimpleSession { readonly start: () => Effect.Effect<void, Error, never> readonly stop: () => Effect.Effect<void, Error, never> readonly sendInput: (text: string) => Effect.Effect<void, Error, never> readonly waitForOutput: (text: string, timeout?: number) => Effect.Effect<void, Error, never> readonly getOutput: () => Effect.Effect<string, Error, never> readonly saveScreenshot: (name: string) => Effect.Effect<string, Error, never> } class SimpleHarnessImpl implements SimpleSession { private process: ChildProcess | null = null private outputRef = Ref.unsafeMake("") private screenshotCount = 0 constructor(private options: SimpleHarnessOptions) { if (options.screenshotDir) { mkdirSync(options.screenshotDir, { recursive: true }) } } start(): Effect.Effect<void, Error, never> { return Effect.gen(function* (_) { if (this.process) { yield* _(Effect.fail(new Error("Process already started"))) } this.process = spawn(this.options.command, this.options.args || [], { cwd: this.options.cwd || process.cwd(), env: { ...process.env, ...this.options.env, FORCE_COLOR: "1" }, stdio: ['pipe', 'pipe', 'pipe'] }) // Capture output this.process.stdout?.on('data', (data: Buffer) => { const text = data.toString() Effect.runSync(Ref.update(this.outputRef, current => current + text)) }) this.process.stderr?.on('data', (data: Buffer) => { const text = data.toString() Effect.runSync(Ref.update(this.outputRef, current => current + text)) }) // Wait for process to start yield* _(Effect.sleep(500)) }.bind(this)) } stop(): Effect.Effect<void, Error, never> { return Effect.gen(function* (_) { if (!this.process) { yield* _(Effect.fail(new Error("Process not started"))) } this.process.kill('SIGTERM') this.process = null yield* _(Effect.sleep(100)) }.bind(this)) } sendInput(text: string): Effect.Effect<void, Error, never> { return Effect.gen(function* (_) { if (!this.process || !this.process.stdin) { yield* _(Effect.fail(new Error("Process not started or stdin not available"))) } this.process.stdin.write(text) yield* _(Effect.sleep(100)) }.bind(this)) } waitForOutput(text: string, timeout: number = 5000): Effect.Effect<void, Error, never> { return Effect.gen(function* (_) { const startTime = Date.now() while (true) { const output = yield* _(Ref.get(this.outputRef)) if (output.includes(text)) { return } if (Date.now() - startTime > timeout) { const currentOutput = yield* _(Ref.get(this.outputRef)) yield* _(Effect.fail(new Error( `Timeout waiting for text: "${text}"\nCurrent output:\n${currentOutput}` ))) } yield* _(Effect.sleep(100)) } }.bind(this)) } getOutput(): Effect.Effect<string, Error, never> { return Ref.get(this.outputRef) } saveScreenshot(name: string): Effect.Effect<string, Error, never> { return Effect.gen(function* (_) { const output = yield* _(this.getOutput()) const filename = `${name}-${Date.now()}.txt` const filepath = this.options.screenshotDir ? join(this.options.screenshotDir, filename) : filename writeFileSync(filepath, output) return filepath }.bind(this)) } } export const createSimpleHarness = (options: SimpleHarnessOptions): SimpleSession => { return new SimpleHarnessImpl(options) } export const runSimpleTest = <R, E, A>( options: SimpleHarnessOptions, test: (harness: SimpleSession) => Effect.Effect<A, E, R> ): Effect.Effect<A, E | Error, R> => Effect.gen(function* (_) { const harness = createSimpleHarness(options) yield* _(harness.start()) try { return yield* _(test(harness)) } finally { yield* _(harness.stop()) } })