fetch_ml/tests/fixtures/tui_driver.go
Jeremie Fraeys b4672a6c25
feat: add TUI SSH usability testing infrastructure
Add comprehensive testing for TUI usability over SSH in production-like environment:

Infrastructure:
- Caddy reverse proxy config for WebSocket and API routing
- Docker Compose with SSH test server container
- TUI test configuration for smoke testing

Test Harness:
- SSH server Go test fixture with container management
- TUI driver with PTY support for automated input/output testing
- 8 E2E tests covering SSH connectivity, TERM propagation,
  API/WebSocket connectivity, and TUI configuration

Scripts:
- SSH key generation for test environment
- Manual testing script with interactive TUI verification

The setup allows automated verification that the BubbleTea TUI works
correctly over SSH with proper terminal handling, alt-screen buffer,
and mouse support through Caddy reverse proxy.
2026-02-18 17:48:02 -05:00

182 lines
4 KiB
Go

// Package tests provides TUI test driver for SSH testing
package tests
import (
"bytes"
"fmt"
"io"
"time"
"golang.org/x/crypto/ssh"
)
// TUIDriver provides a driver for interacting with the TUI over SSH
type TUIDriver struct {
session *ssh.Session
stdin io.WriteCloser
stdout io.Reader
stderr io.Reader
outputBuf *bytes.Buffer
width int
height int
}
// NewTUIDriver creates a new TUI driver connected via SSH
func NewTUIDriver(session *ssh.Session, width, height int) (*TUIDriver, error) {
stdin, err := session.StdinPipe()
if err != nil {
return nil, fmt.Errorf("failed to get stdin pipe: %w", err)
}
stdout, err := session.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("failed to get stdout pipe: %w", err)
}
stderr, err := session.StderrPipe()
if err != nil {
return nil, fmt.Errorf("failed to get stderr pipe: %w", err)
}
driver := &TUIDriver{
session: session,
stdin: stdin,
stdout: stdout,
stderr: stderr,
outputBuf: &bytes.Buffer{},
width: width,
height: height,
}
return driver, nil
}
// SendKey sends a single key to the TUI
func (d *TUIDriver) SendKey(key byte) error {
_, err := d.stdin.Write([]byte{key})
return err
}
// SendKeys sends multiple keys to the TUI
func (d *TUIDriver) SendKeys(keys string) error {
_, err := d.stdin.Write([]byte(keys))
return err
}
// SendEscape sends the Escape key
func (d *TUIDriver) SendEscape() error {
return d.SendKey(0x1b) // ESC
}
// SendEnter sends the Enter key
func (d *TUIDriver) SendEnter() error {
return d.SendKey('\n')
}
// SendCtrlC sends Ctrl+C (SIGINT)
func (d *TUIDriver) SendCtrlC() error {
return d.SendKey(0x03) // ETX (Ctrl+C)
}
// SendArrowUp sends the up arrow key
func (d *TUIDriver) SendArrowUp() error {
_, err := d.stdin.Write([]byte{0x1b, '[', 'A'})
return err
}
// SendArrowDown sends the down arrow key
func (d *TUIDriver) SendArrowDown() error {
_, err := d.stdin.Write([]byte{0x1b, '[', 'B'})
return err
}
// SendArrowLeft sends the left arrow key
func (d *TUIDriver) SendArrowLeft() error {
_, err := d.stdin.Write([]byte{0x1b, '[', 'D'})
return err
}
// SendArrowRight sends the right arrow key
func (d *TUIDriver) SendArrowRight() error {
_, err := d.stdin.Write([]byte{0x1b, '[', 'C'})
return err
}
// SendTab sends the Tab key
func (d *TUIDriver) SendTab() error {
return d.SendKey('\t')
}
// CaptureScreen reads the current screen output
func (d *TUIDriver) CaptureScreen(timeout time.Duration) (string, error) {
// Clear previous buffer
d.outputBuf.Reset()
// Read with timeout
done := make(chan error, 1)
go func() {
buf := make([]byte, 4096)
for {
n, err := d.stdout.Read(buf)
if n > 0 {
d.outputBuf.Write(buf[:n])
}
if err != nil {
done <- err
return
}
}
}()
select {
case <-time.After(timeout):
return d.outputBuf.String(), nil
case err := <-done:
if err == io.EOF {
return d.outputBuf.String(), nil
}
return d.outputBuf.String(), err
}
}
// WaitForOutput waits for specific output to appear
func (d *TUIDriver) WaitForOutput(expected string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
d.outputBuf.Reset()
buf := make([]byte, 1024)
for time.Now().Before(deadline) {
n, err := d.stdout.Read(buf)
if n > 0 {
d.outputBuf.Write(buf[:n])
if bytes.Contains(d.outputBuf.Bytes(), []byte(expected)) {
return nil
}
}
if err != nil && err != io.EOF {
// Continue on timeout
time.Sleep(50 * time.Millisecond)
continue
}
// Short sleep to prevent tight loop
time.Sleep(50 * time.Millisecond)
}
return fmt.Errorf("timeout waiting for output containing %q. Got: %s", expected, d.outputBuf.String())
}
// Close closes the TUI driver and session
func (d *TUIDriver) Close() error {
d.stdin.Close()
return d.session.Close()
}
// Wait waits for the session to complete
func (d *TUIDriver) Wait() error {
return d.session.Wait()
}
// Signal sends a signal to the session
func (d *TUIDriver) Signal(sig ssh.Signal) error {
return d.session.Signal(sig)
}