Go 215 lines
//go:build !windows
package tui
import (
"bytes"
"errors"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"syscall"
"testing"
"time"
"github.com/creack/pty"
)
// repoRoot returns the absolute path of the eeco repository root,
// derived from this test file's source location so it works regardless
// of the test runner's CWD.
func repoRoot(t *testing.T) string {
t.Helper()
_, here, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller failed")
}
return filepath.Clean(filepath.Join(filepath.Dir(here), "..", ".."))
}
var (
buildOnce sync.Once
buildPath string
buildErr error
)
// buildBinary compiles cmd/eeco to a session-shared temp file. Building
// once per `go test` invocation amortises the cost across multiple PTY
// scenarios (currently one, but the pattern is cheap to extend).
func buildBinary(t *testing.T) string {
t.Helper()
buildOnce.Do(func() {
dir, err := os.MkdirTemp("", "eeco-pty-")
if err != nil {
buildErr = err
return
}
bin := filepath.Join(dir, "eeco")
cmd := exec.Command("go", "build", "-o", bin, "./cmd/eeco")
cmd.Dir = repoRoot(t)
if out, err := cmd.CombinedOutput(); err != nil {
buildErr = errors.New("go build: " + err.Error() + "\n" + string(out))
return
}
buildPath = bin
})
if buildErr != nil {
t.Fatal(buildErr)
}
return buildPath
}
// scratchInitRepo creates a temp dir, makes it a git repo, runs
// `eeco init`, and returns the path. The returned dir is the CWD the
// subsequent PTY invocation runs in.
func scratchInitRepo(t *testing.T, bin string) string {
t.Helper()
root := t.TempDir()
if err := os.Mkdir(filepath.Join(root, ".git"), 0o755); err != nil {
t.Fatal(err)
}
cmd := exec.Command(bin, "init")
cmd.Dir = root
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("eeco init: %v\n%s", err, out)
}
return root
}
// readUntil consumes from r in a goroutine until marker appears in the
// accumulated buffer or timeout elapses. The PTY master never returns
// EOF until the child exits, so a synchronous Read would block past
// the deadline; the goroutine pattern lets us bound the wait.
func readUntil(t *testing.T, r io.Reader, marker string, timeout time.Duration) string {
t.Helper()
var (
mu sync.Mutex
buf bytes.Buffer
done = make(chan struct{})
)
go func() {
chunk := make([]byte, 4096)
for {
n, err := r.Read(chunk)
if n > 0 {
mu.Lock()
buf.Write(chunk[:n])
if bytes.Contains(buf.Bytes(), []byte(marker)) {
mu.Unlock()
close(done)
return
}
mu.Unlock()
}
if err != nil {
return
}
}
}()
select {
case <-done:
case <-time.After(timeout):
}
mu.Lock()
defer mu.Unlock()
return buf.String()
}
func TestPTY_DigestRendersAndQuitExitsCleanly(t *testing.T) {
if testing.Short() {
t.Skip("PTY test skipped under -short")
}
bin := buildBinary(t)
root := scratchInitRepo(t, bin)
cmd := exec.Command(bin)
cmd.Dir = root
ptmx, err := pty.Start(cmd)
if err != nil {
t.Fatalf("pty.Start: %v", err)
}
defer func() {
_ = ptmx.Close()
if cmd.ProcessState == nil || !cmd.ProcessState.Exited() {
_ = cmd.Process.Kill()
_, _ = cmd.Process.Wait()
}
}()
_ = pty.Setsize(ptmx, &pty.Winsize{Rows: 24, Cols: 100})
// Bubble Tea probes the terminal at startup with OSC 11 (background
// color) and DSR (cursor position) and waits for replies before
// the first View renders. Feed canned answers so the loop proceeds.
go func() {
time.Sleep(50 * time.Millisecond)
_, _ = ptmx.Write([]byte("\x1b]11;rgb:0000/0000/0000\x1b\\"))
_, _ = ptmx.Write([]byte("\x1b[1;1R"))
}()
first := readUntil(t, ptmx, "auto:propose", 8*time.Second)
// The workspace name `.eeco/` rides in the bar once `eeco init` has
// run; serves as the cross-render proof that the interactive surface
// actually painted (the bar no longer carries a `eeco vX` banner —
// the home block printed once at session start owns that).
if !strings.Contains(first, ".eeco/") {
t.Fatalf("digest missing workspace field:\n%q", first)
}
if !strings.Contains(first, "auto:propose") {
t.Fatalf("digest missing automation field:\n%q", first)
}
// Open the ? overlay — its content begins with "commands:".
if _, err := ptmx.Write([]byte("?")); err != nil {
t.Fatalf("write ?: %v", err)
}
overlay := readUntil(t, ptmx, "commands:", 3*time.Second)
if !strings.Contains(overlay, "commands:") {
t.Fatalf("? overlay did not render commands header:\n%q", overlay)
}
// Drain PTY output until the child exits. readUntil's goroutine
// returned when "commands:" was found; without a continuous reader,
// the PTY buffer fills and Bubble Tea's teardown writes block, which
// can stall tea.Quit. io.Copy returns on EOF when the child exits
// and ptmx.Close() runs in the deferred cleanup.
go func() { _, _ = io.Copy(io.Discard, ptmx) }()
// Dismiss overlay (any key) then quit via Ctrl-C. The slash-command
// path (`/quit` + Enter) routes through tea.Sequence(echo, tea.Quit)
// which has been observed to stall on slow Linux PTY runners even
// with the output drainer in place. Ctrl-C is the direct quit hook
// (model.onKey at internal/tui/model.go:102-104) and bypasses both
// the slash parser and tea.Sequence — exactly the contract a TUI
// must honour, and the most stable assertion for cross-platform CI.
if _, err := ptmx.Write([]byte(" ")); err != nil {
t.Fatalf("dismiss overlay: %v", err)
}
time.Sleep(80 * time.Millisecond)
if _, err := ptmx.Write([]byte{0x03}); err != nil {
t.Fatalf("write Ctrl-C: %v", err)
}
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
select {
case werr := <-done:
if werr != nil {
var ee *exec.ExitError
if errors.As(werr, &ee) && ee.ExitCode() != 0 {
t.Fatalf("eeco exited non-zero: %v", werr)
}
if !errors.As(werr, &ee) {
t.Fatalf("eeco wait: %v", werr)
}
}
case <-time.After(15 * time.Second):
_ = cmd.Process.Signal(syscall.SIGTERM)
<-done
t.Fatal("eeco did not exit within 15s of Ctrl-C")
}
}