183 lines
4.7 KiB
Go
183 lines
4.7 KiB
Go
|
|
package main
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"io"
|
||
|
|
"net"
|
||
|
|
"net/http"
|
||
|
|
"os"
|
||
|
|
"path/filepath"
|
||
|
|
"sync/atomic"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/coder/websocket"
|
||
|
|
)
|
||
|
|
|
||
|
|
func TestSocketReconnect(t *testing.T) {
|
||
|
|
root := t.TempDir()
|
||
|
|
socket := filepath.Join(root, "rpc.sock")
|
||
|
|
listener, err := net.Listen("unix", socket)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
var connects atomic.Int32
|
||
|
|
var live atomic.Int32
|
||
|
|
ready := make(chan struct{}, 1)
|
||
|
|
server := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
ws, err := websocket.Accept(w, r, nil)
|
||
|
|
if err != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
live.Add(1)
|
||
|
|
defer live.Add(-1)
|
||
|
|
defer func() { _ = ws.CloseNow() }()
|
||
|
|
drop := connects.Add(1) == 1
|
||
|
|
for {
|
||
|
|
_, data, err := ws.Read(r.Context())
|
||
|
|
if err != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
var m message
|
||
|
|
if json.Unmarshal(data, &m) != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if len(m.ID) == 0 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
var result any = struct{}{}
|
||
|
|
switch m.Method {
|
||
|
|
case "initialize":
|
||
|
|
var init initParams
|
||
|
|
if json.Unmarshal(m.Params, &init) != nil || !init.Capabilities.Experimental {
|
||
|
|
t.Error("experimental API not enabled")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
case "thread/loaded/list":
|
||
|
|
if drop {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
result = loadedResult{Data: []string{}}
|
||
|
|
select {
|
||
|
|
case ready <- struct{}{}:
|
||
|
|
default:
|
||
|
|
}
|
||
|
|
default:
|
||
|
|
t.Errorf("unexpected RPC: %s", m.Method)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
response, err := json.Marshal(struct {
|
||
|
|
ID json.RawMessage `json:"id"`
|
||
|
|
Result any `json:"result"`
|
||
|
|
}{m.ID, result})
|
||
|
|
if err != nil {
|
||
|
|
t.Error(err)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if ws.Write(r.Context(), websocket.MessageText, response) != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|
||
|
|
})}
|
||
|
|
served := make(chan error, 1)
|
||
|
|
go func() { served <- server.Serve(listener) }()
|
||
|
|
defer func() {
|
||
|
|
if err := server.Close(); err != nil {
|
||
|
|
t.Error(err)
|
||
|
|
}
|
||
|
|
if err := <-served; err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||
|
|
t.Error(err)
|
||
|
|
}
|
||
|
|
}()
|
||
|
|
opts := options{socket: socket, state: filepath.Join(root, "state.json"), base: 10 * time.Millisecond, cap: 40 * time.Millisecond, poll: 10 * time.Millisecond}
|
||
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||
|
|
defer cancel()
|
||
|
|
done := make(chan error, 1)
|
||
|
|
go func() { done <- run(ctx, opts, io.Discard) }()
|
||
|
|
select {
|
||
|
|
case <-ready:
|
||
|
|
case err := <-done:
|
||
|
|
t.Fatalf("operator exited early: %v", err)
|
||
|
|
case <-ctx.Done():
|
||
|
|
if err := <-done; err != nil {
|
||
|
|
t.Error(err)
|
||
|
|
}
|
||
|
|
t.Fatal("operator did not reconnect")
|
||
|
|
}
|
||
|
|
cancel()
|
||
|
|
if err := <-done; err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
deadline := time.Now().Add(time.Second)
|
||
|
|
for live.Load() != 0 && time.Now().Before(deadline) {
|
||
|
|
time.Sleep(time.Millisecond)
|
||
|
|
}
|
||
|
|
if connects.Load() < 2 || live.Load() != 0 {
|
||
|
|
t.Fatalf("connections: total=%d live=%d", connects.Load(), live.Load())
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestOptions(t *testing.T) {
|
||
|
|
for _, args := range [][]string{{"--base-delay", "0s"}, {"--base-delay", "2m", "--max-delay", "1m"}, {"--poll", "0s"}, {"--state", ""}, {"resume", "thread-1"}} {
|
||
|
|
if _, err := parseOptions(args, io.Discard); err == nil {
|
||
|
|
t.Fatal(args)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
opts, err := parseOptions([]string{"--base-delay", "1s", "--max-delay", "1m"}, io.Discard)
|
||
|
|
if err != nil || opts.base != time.Second || opts.cap != time.Minute {
|
||
|
|
t.Fatalf("options = %+v, %v", opts, err)
|
||
|
|
}
|
||
|
|
t.Setenv("CODEX_HOME", "/tmp/synthetic-codex")
|
||
|
|
if socketPath() != "/tmp/synthetic-codex/app-server-control/app-server-control.sock" {
|
||
|
|
t.Fatal(socketPath())
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestDaemonRead(t *testing.T) {
|
||
|
|
socket := os.Getenv("KEEP_LIVE_SOCKET")
|
||
|
|
if socket == "" {
|
||
|
|
t.Skip("KEEP_LIVE_SOCKET is unset")
|
||
|
|
}
|
||
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
|
|
defer cancel()
|
||
|
|
c, err := connect(ctx, socket)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
defer c.stop()
|
||
|
|
var init initParams
|
||
|
|
init.Client.Name, init.Client.Version = "codex-keep-probe", "0.1.0"
|
||
|
|
init.Capabilities.Experimental = true
|
||
|
|
if err := c.call(ctx, "initialize", init, nil); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if err := c.send(0, "initialized", struct{}{}); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
var page loadedResult
|
||
|
|
if err := c.call(ctx, "thread/loaded/list", struct{}{}, &page); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
statuses := make(map[goalStatus]int)
|
||
|
|
for _, id := range page.Data {
|
||
|
|
var result goalResult
|
||
|
|
if err := c.call(ctx, "thread/goal/get", threadParams{id}, &result); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
if result.Goal != nil {
|
||
|
|
statuses[result.Goal.Status]++
|
||
|
|
var turns turnsResult
|
||
|
|
p := struct {
|
||
|
|
ThreadID string `json:"threadId"`
|
||
|
|
Limit int `json:"limit"`
|
||
|
|
View string `json:"itemsView"`
|
||
|
|
}{id, 1, "summary"}
|
||
|
|
if err := c.call(ctx, "thread/turns/list", p, &turns); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
t.Logf("loaded=%d goals=%v; read-only", len(page.Data), statuses)
|
||
|
|
}
|