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

145 lines
3.9 KiB
Go

package terminal
import (
"context"
"io"
"log/slog"
"testing"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
)
func testLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
func newTestSession(src PTYSource, spawn spawnFunc) *session {
return newSession("t1", ports.RuntimeHandle{ID: "t1"}, src, spawn, testLogger())
}
func TestSessionFansOutLiveOutputToSubscribers(t *testing.T) {
src := &fakeSource{}
pty := newFakePTY()
sp := &fakeSpawner{ptys: []*fakePTY{pty}}
s := newTestSession(src, sp.spawn)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go s.run(ctx)
var a, b safeBytes
s.subscribe(a.add, nil)
s.subscribe(b.add, nil)
pty.push([]byte("hello"))
eventually(t, time.Second, func() bool { return a.string() == "hello" && b.string() == "hello" })
}
func TestSessionReplaysRingBufferOnSubscribe(t *testing.T) {
src := &fakeSource{}
pty := newFakePTY()
sp := &fakeSpawner{ptys: []*fakePTY{pty}}
s := newTestSession(src, sp.spawn)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go s.run(ctx)
pty.push([]byte("scrollback"))
eventually(t, time.Second, func() bool { return ringLen(s) == len("scrollback") })
var late safeBytes
s.subscribe(late.add, nil)
eventually(t, time.Second, func() bool { return late.string() == "scrollback" })
}
func ringLen(s *session) int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.ring.snapshot())
}
func TestSessionWriteAndResizeReachPTY(t *testing.T) {
src := &fakeSource{}
pty := newFakePTY()
sp := &fakeSpawner{ptys: []*fakePTY{pty}}
s := newTestSession(src, sp.spawn)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go s.run(ctx)
eventually(t, time.Second, func() bool { return s.write([]byte("ls\n")) == nil })
eventually(t, time.Second, func() bool { return string(pty.writtenBytes()) == "ls\n" })
if err := s.resize(24, 80); err != nil {
t.Fatalf("resize: %v", err)
}
eventually(t, time.Second, func() bool {
rs := pty.resizeCalls()
return len(rs) == 1 && rs[0] == [2]uint16{24, 80}
})
}
func TestSessionSkipsReattachOnCleanExit(t *testing.T) {
src := &fakeSource{alive: false} // Zellij session gone -> no re-attach
pty := newFakePTY()
sp := &fakeSpawner{ptys: []*fakePTY{pty}}
s := newTestSession(src, sp.spawn)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
exited := make(chan struct{})
go s.run(ctx)
s.subscribe(func([]byte) {}, func() { close(exited) })
pty.Close() // pane ends
select {
case <-exited:
case <-time.After(time.Second):
t.Fatal("expected exit callback after clean pane exit")
}
if got := sp.calls(); got != 1 {
t.Fatalf("expected exactly one attach, got %d", got)
}
}
func TestSessionReattachesWhileSessionAlive(t *testing.T) {
src := &fakeSource{alive: true} // session still alive -> re-attach on drop
p1, p2 := newFakePTY(), newFakePTY()
sp := &fakeSpawner{ptys: []*fakePTY{p1, p2}}
s := newTestSession(src, sp.spawn)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go s.run(ctx)
eventually(t, time.Second, func() bool { return sp.calls() >= 1 })
p1.Close() // first attach drops
eventually(t, 2*time.Second, func() bool { return sp.calls() >= 2 })
// Now the session is gone: the next drop must not re-attach.
src.setAlive(false)
p2.Close()
eventually(t, 2*time.Second, func() bool { return s.isExited() })
}
func TestSessionFailsWhenAttachCommandErrors(t *testing.T) {
src := &fakeSource{attachErr: io.ErrUnexpectedEOF}
sp := &fakeSpawner{}
s := newTestSession(src, sp.spawn)
exited := make(chan struct{})
s.subscribe(func([]byte) {}, func() { close(exited) })
go s.run(context.Background())
select {
case <-exited:
case <-time.After(time.Second):
t.Fatal("expected exit when attach command fails")
}
if sp.calls() != 0 {
t.Fatalf("spawn should not run when attach command errors, got %d calls", sp.calls())
}
}