|
| 1 | +//go:build !windows |
| 2 | + |
| 3 | +package app |
| 4 | + |
| 5 | +import ( |
| 6 | + "bytes" |
| 7 | + "io" |
| 8 | + "os" |
| 9 | + "os/exec" |
| 10 | + "path/filepath" |
| 11 | + "strings" |
| 12 | + "sync" |
| 13 | + "testing" |
| 14 | + "time" |
| 15 | + |
| 16 | + "github.com/creack/pty" |
| 17 | +) |
| 18 | + |
| 19 | +const ptyHelperEnv = "AGENTS_PTY_HELPER" |
| 20 | + |
| 21 | +// TestHelperProcess re-runs the CLI inside the test binary so the PTY test |
| 22 | +// can drive `agents go` as a real subprocess. It is inert in normal runs. |
| 23 | +func TestHelperProcess(t *testing.T) { |
| 24 | + if os.Getenv(ptyHelperEnv) != "1" { |
| 25 | + t.Skip("helper process only runs under the PTY e2e test") |
| 26 | + } |
| 27 | + args := os.Args |
| 28 | + for i, arg := range args { |
| 29 | + if arg == "--" { |
| 30 | + args = args[i+1:] |
| 31 | + break |
| 32 | + } |
| 33 | + } |
| 34 | + os.Exit(Run(args, os.Stdin, os.Stdout, os.Stderr)) |
| 35 | +} |
| 36 | + |
| 37 | +// TestGoUnifiedViewUnderPTY proves the TUI path end-to-end on a real |
| 38 | +// pseudo-terminal: the split view renders both lanes live, the loop reaches |
| 39 | +// the human boundary, the Ready-for-you screen shows the evidence, and |
| 40 | +// accept archives the task with a suggested commit message. |
| 41 | +func TestGoUnifiedViewUnderPTY(t *testing.T) { |
| 42 | + if testing.Short() { |
| 43 | + t.Skip("PTY e2e test skipped in -short mode") |
| 44 | + } |
| 45 | + home := t.TempDir() |
| 46 | + cwd := t.TempDir() |
| 47 | + // macOS resolves /var to /private/var; the workspace trust store records |
| 48 | + // the resolved path, so use it consistently. |
| 49 | + if resolved, err := filepath.EvalSymlinks(cwd); err == nil { |
| 50 | + cwd = resolved |
| 51 | + } |
| 52 | + |
| 53 | + writeStub := func(name, body string) string { |
| 54 | + path := filepath.Join(cwd, name) |
| 55 | + if err := os.WriteFile(path, []byte(body), 0o755); err != nil { |
| 56 | + t.Fatal(err) |
| 57 | + } |
| 58 | + return path |
| 59 | + } |
| 60 | + // Stub records use far-future timestamps so they sort after go's own |
| 61 | + // handoff; the scripts locate the task dir by glob since go creates it. |
| 62 | + implementer := writeStub("implementer.sh", strings.Join([]string{ |
| 63 | + "#!/bin/sh", |
| 64 | + "set -eu", |
| 65 | + "echo \"editing main.go\"", |
| 66 | + "task_dir=$(echo .agents/tasks/*)", |
| 67 | + "mkdir -p \"$task_dir/handoffs\"", |
| 68 | + "cat > \"$task_dir/handoffs/2099-01-01T000100Z-handoff-to-reviewer.md\" <<'EOF'", |
| 69 | + "# Handoff: reviewer", |
| 70 | + "", |
| 71 | + "To: reviewer", |
| 72 | + "", |
| 73 | + "Ready for review.", |
| 74 | + "EOF", |
| 75 | + "", |
| 76 | + }, "\n")) |
| 77 | + reviewer := writeStub("reviewer.sh", strings.Join([]string{ |
| 78 | + "#!/bin/sh", |
| 79 | + "set -eu", |
| 80 | + "echo \"checking diff\"", |
| 81 | + "task_dir=$(echo .agents/tasks/*)", |
| 82 | + "mkdir -p \"$task_dir/verification\"", |
| 83 | + "cat > \"$task_dir/verification/2099-01-01T000200Z-checks.md\" <<'EOF'", |
| 84 | + "# Verification: checks", |
| 85 | + "", |
| 86 | + "Result: pass", |
| 87 | + "By: reviewer", |
| 88 | + "", |
| 89 | + "All checks pass.", |
| 90 | + "EOF", |
| 91 | + "", |
| 92 | + }, "\n")) |
| 93 | + if err := os.MkdirAll(filepath.Join(cwd, ".agents"), 0o755); err != nil { |
| 94 | + t.Fatal(err) |
| 95 | + } |
| 96 | + cfg := "roles:\n implementer: " + implementer + " {prompt_file}\n reviewer: " + reviewer + " {prompt_file}\n coordinator: codex\n" |
| 97 | + if err := os.WriteFile(filepath.Join(cwd, ".agents", "config.yml"), []byte(cfg), 0o644); err != nil { |
| 98 | + t.Fatal(err) |
| 99 | + } |
| 100 | + if _, err := trustWorkspace(env{cwd: cwd, home: home, trustPath: filepath.Join(home, ".agents", "trust.json")}); err != nil { |
| 101 | + t.Fatal(err) |
| 102 | + } |
| 103 | + |
| 104 | + cmd := exec.Command(os.Args[0], "-test.run=TestHelperProcess", "--", "go", "History: support /history in the terminal") |
| 105 | + cmd.Dir = cwd |
| 106 | + cmd.Env = append(os.Environ(), ptyHelperEnv+"=1", "HOME="+home) |
| 107 | + ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: 30, Cols: 100}) |
| 108 | + if err != nil { |
| 109 | + t.Skipf("cannot allocate pty: %v", err) |
| 110 | + } |
| 111 | + defer func() { _ = ptmx.Close() }() |
| 112 | + |
| 113 | + var mu sync.Mutex |
| 114 | + var output bytes.Buffer |
| 115 | + readDone := make(chan struct{}) |
| 116 | + go func() { |
| 117 | + defer close(readDone) |
| 118 | + buf := make([]byte, 4096) |
| 119 | + for { |
| 120 | + n, readErr := ptmx.Read(buf) |
| 121 | + if n > 0 { |
| 122 | + mu.Lock() |
| 123 | + output.Write(buf[:n]) |
| 124 | + mu.Unlock() |
| 125 | + } |
| 126 | + if readErr != nil { |
| 127 | + return |
| 128 | + } |
| 129 | + } |
| 130 | + }() |
| 131 | + snapshot := func() string { |
| 132 | + mu.Lock() |
| 133 | + defer mu.Unlock() |
| 134 | + return output.String() |
| 135 | + } |
| 136 | + waitFor := func(marker string) { |
| 137 | + t.Helper() |
| 138 | + deadline := time.Now().Add(30 * time.Second) |
| 139 | + for time.Now().Before(deadline) { |
| 140 | + if strings.Contains(snapshot(), marker) { |
| 141 | + return |
| 142 | + } |
| 143 | + time.Sleep(50 * time.Millisecond) |
| 144 | + } |
| 145 | + t.Fatalf("timed out waiting for %q in PTY output:\n%s", marker, snapshot()) |
| 146 | + } |
| 147 | + |
| 148 | + // The loop runs to the human boundary, then the view flips to the |
| 149 | + // Ready-for-you screen; accept archives and suggests a commit message. |
| 150 | + waitFor("Your call:") |
| 151 | + if _, err := io.WriteString(ptmx, "a"); err != nil { |
| 152 | + t.Fatal(err) |
| 153 | + } |
| 154 | + |
| 155 | + waitErr := make(chan error, 1) |
| 156 | + go func() { waitErr <- cmd.Wait() }() |
| 157 | + select { |
| 158 | + case err := <-waitErr: |
| 159 | + if err != nil { |
| 160 | + t.Fatalf("agents go exited with error: %v\n%s", err, snapshot()) |
| 161 | + } |
| 162 | + case <-time.After(30 * time.Second): |
| 163 | + _ = cmd.Process.Kill() |
| 164 | + t.Fatalf("agents go did not exit after accept:\n%s", snapshot()) |
| 165 | + } |
| 166 | + _ = ptmx.Close() |
| 167 | + <-readDone |
| 168 | + |
| 169 | + got := snapshot() |
| 170 | + for _, want := range []string{ |
| 171 | + "Building", // split view lane |
| 172 | + "Checking", // split view lane |
| 173 | + "editing main.go", // live implementer output |
| 174 | + "checking diff", // live reviewer output |
| 175 | + "Ready for you — History", // boundary screen |
| 176 | + "support /history in the terminal", // scope section |
| 177 | + "Verification evidence:", // evidence section |
| 178 | + "archived task: History", // accept archived |
| 179 | + "Suggested commit message", // accept suggested a message |
| 180 | + "[q]uit [p]ause", // key hints while running |
| 181 | + } { |
| 182 | + if !strings.Contains(got, want) { |
| 183 | + t.Fatalf("PTY output missing %q:\n%s", want, got) |
| 184 | + } |
| 185 | + } |
| 186 | + if _, err := os.Stat(filepath.Join(cwd, ".agents", "archive")); err != nil { |
| 187 | + t.Fatalf("expected archive directory after accept: %v", err) |
| 188 | + } |
| 189 | + if active := readActiveTask(cwd); active.Path != "" { |
| 190 | + t.Fatalf("expected active task cleared after accept, got %+v", active) |
| 191 | + } |
| 192 | +} |
0 commit comments