126 lines
3.6 KiB
Go
126 lines
3.6 KiB
Go
package httpd
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"net/http/httptest"
|
|
"runtime"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/coder/websocket"
|
|
"github.com/coder/websocket/wsjson"
|
|
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/adapters/runtime/ptyexec"
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/config"
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
|
|
"github.com/aoagents/agent-orchestrator/backend/internal/terminal"
|
|
)
|
|
|
|
// stubSource attaches a throwaway shell command instead of a real mux pane, so
|
|
// the /mux path exercises the genuine upgrade + wsjson + Serve + creack/pty flow
|
|
// without needing a runtime. The pane reports alive until the first attach
|
|
// happens (the mux refuses to attach to a dead pane), then dead, so the
|
|
// command's exit is treated as the pane being gone (no re-attach).
|
|
type stubSource struct {
|
|
argv []string
|
|
attached atomic.Bool
|
|
}
|
|
|
|
func (s *stubSource) Attach(ctx context.Context, _ ports.RuntimeHandle, rows, cols uint16) (ports.Stream, error) {
|
|
s.attached.Store(true)
|
|
return ptyexec.Spawn(ctx, s.argv, nil, rows, cols)
|
|
}
|
|
|
|
func (s *stubSource) IsAlive(context.Context, ports.RuntimeHandle) (bool, error) {
|
|
return !s.attached.Load(), nil
|
|
}
|
|
|
|
type terminalMuxFrame struct {
|
|
Ch string `json:"ch"`
|
|
ID string `json:"id"`
|
|
Type string `json:"type"`
|
|
Data string `json:"data"`
|
|
}
|
|
|
|
func dialMux(t *testing.T, mgr *terminal.Manager) (*websocket.Conn, func()) {
|
|
t.Helper()
|
|
router := newTestRouter(config.Config{}, discardLogger(), mgr)
|
|
ts := httptest.NewServer(router)
|
|
url := "ws" + strings.TrimPrefix(ts.URL, "http") + "/mux"
|
|
|
|
c, _, err := websocket.Dial(context.Background(), url, nil)
|
|
if err != nil {
|
|
ts.Close()
|
|
t.Fatalf("dial /mux: %v", err)
|
|
}
|
|
return c, func() {
|
|
_ = c.Close(websocket.StatusNormalClosure, "test done")
|
|
ts.Close()
|
|
}
|
|
}
|
|
|
|
func readFrame(t *testing.T, c *websocket.Conn, ch, typ string, d time.Duration) terminalMuxFrame {
|
|
t.Helper()
|
|
ctx, cancel := context.WithTimeout(context.Background(), d)
|
|
defer cancel()
|
|
for {
|
|
var f terminalMuxFrame
|
|
if err := wsjson.Read(ctx, c, &f); err != nil {
|
|
t.Fatalf("waiting for %s/%s: %v", ch, typ, err)
|
|
}
|
|
if f.Ch == ch && f.Type == typ {
|
|
return f
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMuxUpgradeStreamsTerminal(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("PTY spawning not supported on Windows")
|
|
}
|
|
mgr := terminal.NewManager(
|
|
&stubSource{argv: []string{"/bin/sh", "-c", "printf MUXOK; exit 0"}},
|
|
nil, discardLogger(),
|
|
)
|
|
defer mgr.Close()
|
|
|
|
c, done := dialMux(t, mgr)
|
|
defer done()
|
|
|
|
ctx := context.Background()
|
|
if err := wsjson.Write(ctx, c, terminalMuxFrame{Ch: "terminal", ID: "t1", Type: "open"}); err != nil {
|
|
t.Fatalf("write open: %v", err)
|
|
}
|
|
|
|
readFrame(t, c, "terminal", "opened", 3*time.Second)
|
|
|
|
data := readFrame(t, c, "terminal", "data", 5*time.Second)
|
|
got, _ := base64.StdEncoding.DecodeString(data.Data)
|
|
if !strings.Contains(string(got), "MUXOK") {
|
|
t.Fatalf("streamed data = %q, want it to contain MUXOK", got)
|
|
}
|
|
|
|
// The shell exits; the pane is reported gone (IsAlive=false) so we get exited.
|
|
readFrame(t, c, "terminal", "exited", 5*time.Second)
|
|
}
|
|
|
|
func TestMuxSystemPingPong(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("PTY spawning not supported on Windows")
|
|
}
|
|
mgr := terminal.NewManager(&stubSource{argv: []string{"/bin/sh"}}, nil, discardLogger())
|
|
defer mgr.Close()
|
|
|
|
c, done := dialMux(t, mgr)
|
|
defer done()
|
|
|
|
ctx := context.Background()
|
|
if err := wsjson.Write(ctx, c, map[string]string{"ch": "system", "type": "ping"}); err != nil {
|
|
t.Fatalf("write ping: %v", err)
|
|
}
|
|
readFrame(t, c, "system", "pong", 3*time.Second)
|
|
}
|