|
| 1 | +//go:build integration |
| 2 | + |
| 3 | +// PTY integration tests drive the real ratchet TUI through a pseudo-terminal. |
| 4 | +// They test the exact same code path a user sees — Bubbletea renders to the PTY |
| 5 | +// and we read/parse the terminal output. |
| 6 | +// |
| 7 | +// Requires: Ollama running with at least one model, and a configured provider. |
| 8 | +// |
| 9 | +// Run: go test -tags integration ./internal/tui/ -v -timeout 300s -run TestPTY |
| 10 | + |
| 11 | +package tui |
| 12 | + |
| 13 | +import ( |
| 14 | + "bytes" |
| 15 | + "os" |
| 16 | + "os/exec" |
| 17 | + "regexp" |
| 18 | + "strings" |
| 19 | + "testing" |
| 20 | + "time" |
| 21 | + |
| 22 | + "github.com/creack/pty" |
| 23 | +) |
| 24 | + |
| 25 | +// stripANSI removes terminal escape sequences and control characters from output. |
| 26 | +var ansiRegex = regexp.MustCompile(`\x1b[\[\]()][0-9;?]*[a-zA-Z@]|\x1b\].*?\x07|\x1b[=>]|\r`) |
| 27 | + |
| 28 | +func stripANSI(s string) string { |
| 29 | + return ansiRegex.ReplaceAllString(s, "") |
| 30 | +} |
| 31 | + |
| 32 | +// ptySession manages an interactive ratchet session via PTY. |
| 33 | +type ptySession struct { |
| 34 | + t *testing.T |
| 35 | + ptmx *os.File |
| 36 | + cmd *exec.Cmd |
| 37 | + output bytes.Buffer |
| 38 | +} |
| 39 | + |
| 40 | +// startPTY launches ratchet in a PTY with the given args. |
| 41 | +func startPTY(t *testing.T, args ...string) *ptySession { |
| 42 | + t.Helper() |
| 43 | + |
| 44 | + // Build ratchet binary |
| 45 | + bin := t.TempDir() + "/ratchet-pty-test" |
| 46 | + build := exec.Command("go", "build", "-o", bin, "./cmd/ratchet/") |
| 47 | + build.Dir = findRepoRoot(t) |
| 48 | + out, err := build.CombinedOutput() |
| 49 | + if err != nil { |
| 50 | + t.Fatalf("build ratchet: %v\n%s", err, out) |
| 51 | + } |
| 52 | + |
| 53 | + cmd := exec.Command(bin, args...) |
| 54 | + cmd.Env = append(os.Environ(), "TERM=xterm-256color") |
| 55 | + |
| 56 | + ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: 40, Cols: 100}) |
| 57 | + if err != nil { |
| 58 | + t.Fatalf("start pty: %v", err) |
| 59 | + } |
| 60 | + |
| 61 | + s := &ptySession{t: t, ptmx: ptmx, cmd: cmd} |
| 62 | + |
| 63 | + // Background reader that accumulates all output. |
| 64 | + go func() { |
| 65 | + buf := make([]byte, 4096) |
| 66 | + for { |
| 67 | + n, err := ptmx.Read(buf) |
| 68 | + if n > 0 { |
| 69 | + s.output.Write(buf[:n]) |
| 70 | + } |
| 71 | + if err != nil { |
| 72 | + return |
| 73 | + } |
| 74 | + } |
| 75 | + }() |
| 76 | + |
| 77 | + t.Cleanup(func() { |
| 78 | + ptmx.Close() |
| 79 | + cmd.Process.Kill() |
| 80 | + cmd.Wait() |
| 81 | + }) |
| 82 | + |
| 83 | + return s |
| 84 | +} |
| 85 | + |
| 86 | +// send writes text to the PTY (simulates typing). |
| 87 | +func (s *ptySession) send(text string) { |
| 88 | + s.t.Helper() |
| 89 | + _, err := s.ptmx.Write([]byte(text)) |
| 90 | + if err != nil { |
| 91 | + s.t.Fatalf("pty write: %v", err) |
| 92 | + } |
| 93 | +} |
| 94 | + |
| 95 | +// sendLine writes text followed by Enter. |
| 96 | +func (s *ptySession) sendLine(text string) { |
| 97 | + s.send(text + "\r") |
| 98 | +} |
| 99 | + |
| 100 | +// sendCtrl sends a control character (e.g., sendCtrl('c') for Ctrl+C). |
| 101 | +func (s *ptySession) sendCtrl(c byte) { |
| 102 | + s.send(string([]byte{c & 0x1f})) |
| 103 | +} |
| 104 | + |
| 105 | +// waitFor waits until the output contains the given substring (case-insensitive). |
| 106 | +// Returns the full output at the time of match. |
| 107 | +func (s *ptySession) waitFor(substr string, timeout time.Duration) string { |
| 108 | + s.t.Helper() |
| 109 | + deadline := time.Now().Add(timeout) |
| 110 | + lower := strings.ToLower(substr) |
| 111 | + for time.Now().Before(deadline) { |
| 112 | + text := stripANSI(s.output.String()) |
| 113 | + if strings.Contains(strings.ToLower(text), lower) { |
| 114 | + return text |
| 115 | + } |
| 116 | + time.Sleep(100 * time.Millisecond) |
| 117 | + } |
| 118 | + text := stripANSI(s.output.String()) |
| 119 | + s.t.Fatalf("timeout waiting for %q in output:\n%s", substr, text) |
| 120 | + return "" |
| 121 | +} |
| 122 | + |
| 123 | +// snapshot returns the current output (ANSI-stripped). |
| 124 | +func (s *ptySession) snapshot() string { |
| 125 | + return stripANSI(s.output.String()) |
| 126 | +} |
| 127 | + |
| 128 | +// clearOutput resets the output buffer. |
| 129 | +func (s *ptySession) clearOutput() { |
| 130 | + s.output.Reset() |
| 131 | +} |
| 132 | + |
| 133 | +func findRepoRoot(t *testing.T) string { |
| 134 | + t.Helper() |
| 135 | + dir, _ := os.Getwd() |
| 136 | + for { |
| 137 | + if _, err := os.Stat(dir + "/go.mod"); err == nil { |
| 138 | + return dir |
| 139 | + } |
| 140 | + parent := dir[:strings.LastIndex(dir, "/")] |
| 141 | + if parent == dir { |
| 142 | + t.Fatal("could not find repo root") |
| 143 | + } |
| 144 | + dir = parent |
| 145 | + } |
| 146 | +} |
| 147 | + |
| 148 | +// --- Tests --- |
| 149 | + |
| 150 | +func TestPTY_OneShotChat(t *testing.T) { |
| 151 | + s := startPTY(t, "-p", "What is 2+2? Reply with just the number.") |
| 152 | + |
| 153 | + // Wait for the response — should contain "4". |
| 154 | + out := s.waitFor("4", 120*time.Second) |
| 155 | + t.Logf("One-shot output:\n%s", out) |
| 156 | +} |
| 157 | + |
| 158 | +func TestPTY_TUILaunches(t *testing.T) { |
| 159 | + s := startPTY(t) |
| 160 | + |
| 161 | + // Wait for splash screen then press any key to continue. |
| 162 | + s.waitFor("press any key", 30*time.Second) |
| 163 | + s.send(" ") |
| 164 | + |
| 165 | + // Wait for the chat input prompt. |
| 166 | + s.waitFor("Message ratchet", 15*time.Second) |
| 167 | + |
| 168 | + // Send a message. |
| 169 | + s.sendLine("What is 2+2? Reply with just the number.") |
| 170 | + |
| 171 | + // Wait for a response containing "4". |
| 172 | + out := s.waitFor("4", 120*time.Second) |
| 173 | + t.Logf("TUI output after message:\n%s", out) |
| 174 | + |
| 175 | + // Quit with Ctrl+C. |
| 176 | + s.sendCtrl('c') |
| 177 | + time.Sleep(500 * time.Millisecond) |
| 178 | +} |
| 179 | + |
| 180 | +func TestPTY_TUIMultiTurn(t *testing.T) { |
| 181 | + s := startPTY(t) |
| 182 | + |
| 183 | + // Pass splash screen. |
| 184 | + s.waitFor("press any key", 30*time.Second) |
| 185 | + s.send(" ") |
| 186 | + s.waitFor("Message ratchet", 15*time.Second) |
| 187 | + |
| 188 | + // Turn 1 |
| 189 | + s.sendLine("My name is Alice. Remember that.") |
| 190 | + s.waitFor("Alice", 120*time.Second) |
| 191 | + |
| 192 | + // Wait for response to finish streaming before sending next message. |
| 193 | + time.Sleep(5 * time.Second) |
| 194 | + s.clearOutput() |
| 195 | + |
| 196 | + // Turn 2 — the model should recall the name from conversation history. |
| 197 | + s.sendLine("What is my name?") |
| 198 | + out := s.waitFor("Alice", 120*time.Second) |
| 199 | + t.Logf("Multi-turn output:\n%s", out) |
| 200 | + |
| 201 | + s.sendCtrl('c') |
| 202 | +} |
| 203 | + |
| 204 | +func TestPTY_NoThinkingInOutput(t *testing.T) { |
| 205 | + // With think:false, there should be NO thinking panel in one-shot output. |
| 206 | + s := startPTY(t, "-p", "What is 1+1? Reply with just the number.") |
| 207 | + out := s.waitFor("2", 120*time.Second) |
| 208 | + |
| 209 | + lower := strings.ToLower(out) |
| 210 | + if strings.Contains(lower, "thinking") || strings.Contains(lower, "▶ thinking") { |
| 211 | + t.Errorf("thinking panel should not appear with think:false, got:\n%s", out) |
| 212 | + } |
| 213 | + // Verify no reasoning leak (model shouldn't output "let me think" etc.) |
| 214 | + if strings.Contains(lower, "let me") || strings.Contains(lower, "okay,") { |
| 215 | + t.Errorf("reasoning content leaked into output:\n%s", out) |
| 216 | + } |
| 217 | + t.Logf("Output (no thinking expected):\n%s", out) |
| 218 | +} |
| 219 | + |
| 220 | +func TestPTY_ProviderList(t *testing.T) { |
| 221 | + s := startPTY(t, "provider", "list") |
| 222 | + out := s.waitFor("ALIAS", 10*time.Second) |
| 223 | + if !strings.Contains(out, "ollama") { |
| 224 | + t.Logf("warning: no ollama provider in list — may need setup:\n%s", out) |
| 225 | + } |
| 226 | + t.Logf("Provider list:\n%s", out) |
| 227 | +} |
| 228 | + |
| 229 | +func TestPTY_DaemonStatus(t *testing.T) { |
| 230 | + s := startPTY(t, "daemon", "status") |
| 231 | + out := s.waitFor("daemon", 10*time.Second) |
| 232 | + t.Logf("Daemon status:\n%s", out) |
| 233 | +} |
| 234 | + |
| 235 | +func TestPTY_ModelList(t *testing.T) { |
| 236 | + s := startPTY(t, "model", "list") |
| 237 | + // Should show installed models or "No models installed" |
| 238 | + s.waitFor("NAME", 15*time.Second) |
| 239 | + t.Logf("Model list:\n%s", s.snapshot()) |
| 240 | +} |
| 241 | + |
| 242 | +// TestPTY_TextWrapping verifies that long text wraps within the terminal width. |
| 243 | +func TestPTY_TextWrapping(t *testing.T) { |
| 244 | + s := startPTY(t, "-p", "Write a single paragraph of exactly 200 words about Go programming.") |
| 245 | + out := s.waitFor("Go", 120*time.Second) |
| 246 | + |
| 247 | + // Check that no line exceeds 100 columns (our PTY width). |
| 248 | + lines := strings.Split(out, "\n") |
| 249 | + for i, line := range lines { |
| 250 | + clean := stripANSI(line) |
| 251 | + if len(clean) > 105 { // small margin for terminal rendering |
| 252 | + t.Errorf("line %d exceeds terminal width (len=%d): %q", i, len(clean), clean[:50]+"...") |
| 253 | + } |
| 254 | + } |
| 255 | + t.Logf("Wrapping test passed (%d lines)", len(lines)) |
| 256 | +} |
0 commit comments