152 lines
5.4 KiB
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)
|
|
}
|
|
}
|