agent-orchestrator/backend/internal/terminal/attachment_integration_test.go

152 lines
5.4 KiB
Go

//go:build !windows
package terminal
import (
"context"
"os"
"os/exec"
"strconv"
"strings"
"testing"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/tmux"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
// TestAttachmentStreamsRealTmuxPane attaches a real PTY to a real tmux session
// and asserts output streams back, then that killing the session stops the
// attachment without a re-attach storm. Skipped when tmux is unavailable.
func TestAttachmentStreamsRealTmuxPane(t *testing.T) {
if _, err := exec.LookPath("tmux"); err != nil {
t.Skip("tmux unavailable")
}
// See TestAttachmentReattachAdoptsNewSize: tmux needs a usable TERM to attach.
t.Setenv("TERM", "xterm-256color")
name := "ao-term-it-" + strconv.Itoa(os.Getpid())
rt := tmux.New(tmux.Options{Timeout: 10 * time.Second})
handle, err := rt.Create(context.Background(), ports.RuntimeConfig{
SessionID: domain.SessionID(name),
WorkspacePath: t.TempDir(),
Argv: []string{"sh", "-lc", "printf AO_READY\\n; exec sh -i"},
})
if err != nil {
t.Fatalf("Create: %v", err)
}
t.Cleanup(func() { _ = rt.Destroy(context.Background(), handle) })
var got safeBytes
a := newAttachment(name, handle, rt, nil, got.add, nil, testLogger())
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go a.run(ctx)
// Type a unique marker and expect it echoed back through the PTY.
eventually(t, 3*time.Second, func() bool { return a.write([]byte("echo AO_MARKER_42\n")) == nil })
eventually(t, 5*time.Second, func() bool { return strings.Contains(got.string(), "AO_MARKER_42") })
// Kill the session: the attachment must observe it as gone and not re-attach.
if err := rt.Destroy(context.Background(), handle); err != nil {
t.Fatalf("Destroy: %v", err)
}
eventually(t, 5*time.Second, func() bool { return a.isExited() })
}
// TestAttachmentReattachAdoptsNewSize is the end-to-end regression for the
// stale-size desync: client A holds the session at one grid, detaches, and
// client B immediately attaches at a different grid (the frontend's
// remount/reconnect flow). B's tmux client must adopt B's size, not A's.
func TestAttachmentReattachAdoptsNewSize(t *testing.T) {
if _, err := exec.LookPath("tmux"); err != nil {
t.Skip("tmux unavailable")
}
// tmux refuses to attach a client without a usable TERM, printing
// "open terminal failed: terminal does not support clear". The daemon sets a
// default TERM in production (Finder-launched attach fix); CI runners have
// none, so set it here to match the real environment.
t.Setenv("TERM", "xterm-256color")
name := "ao-term-size-it-" + strconv.Itoa(os.Getpid())
rt := tmux.New(tmux.Options{Timeout: 10 * time.Second})
handle, err := rt.Create(context.Background(), ports.RuntimeConfig{
SessionID: domain.SessionID(name),
WorkspacePath: t.TempDir(),
Argv: []string{"sh", "-lc", "printf AO_READY\\n; exec sh -i"},
})
if err != nil {
t.Fatalf("Create: %v", err)
}
t.Cleanup(func() { _ = rt.Destroy(context.Background(), handle) })
attachAt := func(rows, cols uint16) (*attachment, *safeBytes, <-chan struct{}, context.CancelFunc) {
var got safeBytes
opened := make(chan struct{})
a := newAttachment(name, handle, rt, func() { close(opened) }, got.add, nil, testLogger())
if err := a.resize(rows, cols); err != nil {
t.Fatalf("record size: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
go a.run(ctx)
return a, &got, opened, cancel
}
// Client A at 115x37: wait for the pane shell, then detach.
a, _, openedA, cancelA := attachAt(37, 115)
select {
case <-openedA:
case <-time.After(10 * time.Second):
t.Fatal("client A did not attach")
}
a.close()
cancelA()
// Client B re-attaches immediately at 148x40. The inner pane must see B's
// grid (tmux may shave a row/col; assert cols land near 148 and far from 115).
b, gotB, openedB, cancelB := attachAt(40, 148)
defer cancelB()
defer b.close()
select {
case <-openedB:
case <-time.After(10 * time.Second):
t.Fatal("client B did not attach")
}
// Drive the reattached shell until it reports its width. We RESEND the probe
// each iteration: onOpen means the stream accepts input, not that the inner
// `sh -i` is already at a prompt reading stdin after the reattach, so an early
// keystroke can be dropped; retrying covers that. Real tmux + shell output is
// also slow under -race on CI, hence the long deadline. On timeout we dump
// exactly what the pane produced so the failure is self-explaining (e.g. the
// probe echoed but never executed, or stty errored).
var captured string
gotWidth := false
deadline := time.Now().Add(30 * time.Second)
for time.Now().Before(deadline) {
_ = b.write([]byte("echo SIZE:$(stty size)\n"))
time.Sleep(250 * time.Millisecond)
captured = gotB.string()
i := strings.LastIndex(captured, "SIZE:")
if i < 0 {
continue
}
fields := strings.Fields(strings.TrimPrefix(captured[i:], "SIZE:"))
if len(fields) < 2 {
continue
}
cols, err := strconv.Atoi(strings.TrimFunc(fields[1], func(r rune) bool { return r < '0' || r > '9' }))
if err != nil {
continue
}
if cols > 130 { // B's 148 minus any tmux chrome; a stale A-layout reports <=115
gotWidth = true
break
}
}
if !gotWidth {
t.Fatalf("reattached pane never reported B's width (cols>130) within 30s; captured:\n%q", captured)
}
}