Skip to content

Commit d2ac462

Browse files
intel352claude
andcommitted
test: PTY-based TUI integration tests
8 tests that drive the real ratchet TUI through a pseudo-terminal (creack/pty), testing the exact same code path users see: - TestPTY_OneShotChat: one-shot mode returns correct answer - TestPTY_TUILaunches: TUI renders splash, prompt, status bar - TestPTY_TUIMultiTurn: send two messages, model recalls context - TestPTY_NoThinkingInOutput: no thinking/reasoning text with think:false - TestPTY_ProviderList: provider list command output - TestPTY_DaemonStatus: daemon status shows running - TestPTY_ModelList: model list shows installed models - TestPTY_TextWrapping: lines don't exceed terminal width Run: go test -tags integration ./internal/tui/ -v -timeout 600s Requires: Ollama running with a model, configured provider. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f81c027 commit d2ac462

File tree

3 files changed

+259
-0
lines changed

3 files changed

+259
-0
lines changed

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ require (
108108
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect
109109
github.com/containerd/errdefs v1.0.0 // indirect
110110
github.com/containerd/errdefs/pkg v0.3.0 // indirect
111+
github.com/creack/pty v1.1.24 // indirect
111112
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
112113
github.com/deckarep/golang-set/v2 v2.8.0 // indirect
113114
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,8 @@ github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7
253253
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
254254
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
255255
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
256+
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
257+
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
256258
github.com/cucumber/gherkin/go/v26 v26.2.0 h1:EgIjePLWiPeslwIWmNQ3XHcypPsWAHoMCz/YEBKP4GI=
257259
github.com/cucumber/gherkin/go/v26 v26.2.0/go.mod h1:t2GAPnB8maCT4lkHL99BDCVNzCh1d7dBhCLt150Nr/0=
258260
github.com/cucumber/godog v0.15.1 h1:rb/6oHDdvVZKS66hrhpjFQFHjthFSrQBCOI1LwshNTI=

internal/tui/pty_test.go

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
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

Comments
 (0)