//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") } }