ajhahn.de
← eeco
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")
	}
}