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.
205 lines
5.1 KiB
Go
205 lines
5.1 KiB
Go
// Package tests provides test infrastructure for TUI SSH testing
|
|
package tests
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"testing"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/ssh"
|
|
)
|
|
|
|
// SSHTestServer manages an SSH test server container for TUI testing
|
|
type SSHTestServer struct {
|
|
Host string
|
|
Port int
|
|
User string
|
|
PrivateKey []byte
|
|
Signer ssh.Signer
|
|
Container string
|
|
t *testing.T
|
|
}
|
|
|
|
// NewSSHTestServer creates and starts an SSH test server in a Docker container
|
|
func NewSSHTestServer(t *testing.T) *SSHTestServer {
|
|
t.Helper()
|
|
|
|
_, filename, _, ok := runtime.Caller(0)
|
|
if !ok {
|
|
t.Fatal("failed to resolve caller path")
|
|
}
|
|
|
|
// Navigate to repo root
|
|
repoRoot := filepath.Clean(filepath.Join(filepath.Dir(filename), "..", ".."))
|
|
|
|
// Ensure SSH keys exist
|
|
keysDir := filepath.Join(repoRoot, "deployments", "test_keys")
|
|
privateKeyPath := filepath.Join(keysDir, "test_key")
|
|
|
|
if _, err := os.Stat(privateKeyPath); os.IsNotExist(err) {
|
|
t.Skipf("SSH test keys not found at %s. Run: ./scripts/testing/gen-ssh-test-keys.sh", keysDir)
|
|
}
|
|
|
|
// Read private key
|
|
privateKey, err := os.ReadFile(privateKeyPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to read private key: %v", err)
|
|
}
|
|
|
|
signer, err := ssh.ParsePrivateKey(privateKey)
|
|
if err != nil {
|
|
t.Fatalf("failed to parse private key: %v", err)
|
|
}
|
|
|
|
server := &SSHTestServer{
|
|
Host: "localhost",
|
|
Port: 2222,
|
|
User: "test",
|
|
PrivateKey: privateKey,
|
|
Signer: signer,
|
|
Container: "ml-ssh-test",
|
|
t: t,
|
|
}
|
|
|
|
// Verify container is running
|
|
if err := server.waitForContainer(); err != nil {
|
|
t.Skipf("SSH test container not available: %v. Run: docker-compose -f deployments/docker-compose.prod.smoke.yml up -d ssh-test-server", err)
|
|
}
|
|
|
|
// Verify SSH connectivity
|
|
if err := server.waitForSSH(); err != nil {
|
|
t.Fatalf("SSH server not reachable: %v", err)
|
|
}
|
|
|
|
return server
|
|
}
|
|
|
|
// waitForContainer checks if the SSH container is running
|
|
func (s *SSHTestServer) waitForContainer() error {
|
|
cmd := exec.Command("docker", "ps", "-q", "-f", fmt.Sprintf("name=%s", s.Container))
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check container status: %w", err)
|
|
}
|
|
if len(output) == 0 {
|
|
return fmt.Errorf("container %s is not running", s.Container)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// waitForSSH waits for the SSH server to accept connections
|
|
func (s *SSHTestServer) waitForSSH() error {
|
|
config := &ssh.ClientConfig{
|
|
User: s.User,
|
|
Auth: []ssh.AuthMethod{
|
|
ssh.PublicKeys(s.Signer),
|
|
},
|
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec // Test only
|
|
Timeout: 5 * time.Second,
|
|
}
|
|
|
|
addr := fmt.Sprintf("%s:%d", s.Host, s.Port)
|
|
|
|
// Retry with backoff
|
|
for i := 0; i < 10; i++ {
|
|
client, err := ssh.Dial("tcp", addr, config)
|
|
if err == nil {
|
|
client.Close()
|
|
return nil
|
|
}
|
|
time.Sleep(500 * time.Millisecond)
|
|
}
|
|
|
|
return fmt.Errorf("SSH server not available after retries")
|
|
}
|
|
|
|
// NewClient creates a new SSH client connection
|
|
func (s *SSHTestServer) NewClient() (*ssh.Client, error) {
|
|
config := &ssh.ClientConfig{
|
|
User: s.User,
|
|
Auth: []ssh.AuthMethod{
|
|
ssh.PublicKeys(s.Signer),
|
|
},
|
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec // Test only
|
|
Timeout: 10 * time.Second,
|
|
}
|
|
|
|
addr := fmt.Sprintf("%s:%d", s.Host, s.Port)
|
|
return ssh.Dial("tcp", addr, config)
|
|
}
|
|
|
|
// Stop stops the SSH test server container
|
|
func (s *SSHTestServer) Stop() {
|
|
// Container is managed by docker-compose, so we don't stop it here
|
|
// This allows multiple tests to reuse the same container
|
|
}
|
|
|
|
// Cleanup stops and removes the SSH test container
|
|
func (s *SSHTestServer) Cleanup() {
|
|
cmd := exec.Command("docker", "stop", s.Container)
|
|
_ = cmd.Run()
|
|
|
|
cmd = exec.Command("docker", "rm", s.Container)
|
|
_ = cmd.Run()
|
|
}
|
|
|
|
// Exec runs a command on the SSH server and returns output
|
|
func (s *SSHTestServer) Exec(cmd string) (string, error) {
|
|
client, err := s.NewClient()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to connect: %w", err)
|
|
}
|
|
defer client.Close()
|
|
|
|
session, err := client.NewSession()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create session: %w", err)
|
|
}
|
|
defer session.Close()
|
|
|
|
output, err := session.CombinedOutput(cmd)
|
|
if err != nil {
|
|
return string(output), fmt.Errorf("command failed: %w", err)
|
|
}
|
|
|
|
return string(output), nil
|
|
}
|
|
|
|
// ExecWithPTY runs a command with PTY allocation for interactive TUI testing
|
|
func (s *SSHTestServer) ExecWithPTY(cmd string, term string, width, height int) (*ssh.Session, error) {
|
|
client, err := s.NewClient()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to connect: %w", err)
|
|
}
|
|
|
|
session, err := client.NewSession()
|
|
if err != nil {
|
|
client.Close()
|
|
return nil, fmt.Errorf("failed to create session: %w", err)
|
|
}
|
|
|
|
// Request PTY
|
|
modes := ssh.TerminalModes{
|
|
ssh.ECHO: 0,
|
|
ssh.TTY_OP_ISPEED: 14400,
|
|
ssh.TTY_OP_OSPEED: 14400,
|
|
}
|
|
|
|
if err := session.RequestPty(term, width, height, modes); err != nil {
|
|
session.Close()
|
|
client.Close()
|
|
return nil, fmt.Errorf("failed to request pty: %w", err)
|
|
}
|
|
|
|
if err := session.Start(cmd); err != nil {
|
|
session.Close()
|
|
client.Close()
|
|
return nil, fmt.Errorf("failed to start command: %w", err)
|
|
}
|
|
|
|
return session, nil
|
|
}
|