- Random port via net.Listen(":0") replaces hardcoded 33310 (was the
primary failure mode under parallel test load).
- Bump waitForPort deadline 5s → 30s — `go build` under -race can exceed
5s on a loaded machine.
- Replace osPath() (always returned empty PATH because exec.Command("env").Env
is the *child's* env, not the parent's) with explicit PATH+HOME via
os.Getenv. Don't inherit full env: would leak ROUTING_MCP_TOKEN from the
parent shell and flip the routing pod into auth-required mode, breaking
the test.
Closes #15. Verified: 10 cold-cache test runs pass, 3 consecutive task check
runs pass.
136 lines
4.0 KiB
Go
136 lines
4.0 KiB
Go
package main_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"os/exec"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// TestRoutingPodEndToEnd boots the binary against fake LiteLLM + brain servers,
|
|
// calls tools/list and one tools/call, and verifies the brain saw a session_log POST.
|
|
func TestRoutingPodEndToEnd(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("end-to-end binary boot")
|
|
}
|
|
|
|
var brainHits int
|
|
llm := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"choices": []map[string]any{{"message": map[string]any{"role": "assistant", "content": "stub"}}},
|
|
})
|
|
}))
|
|
defer llm.Close()
|
|
|
|
brain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/pass-rate":
|
|
brainHits++
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"pass_rate": 0.95})
|
|
case "/mcp":
|
|
brainHits++
|
|
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": 1, "result": map[string]any{}})
|
|
}
|
|
}))
|
|
defer brain.Close()
|
|
|
|
port := freePort(t)
|
|
addr := "127.0.0.1:" + port
|
|
baseURL := "http://" + addr
|
|
|
|
bin := buildRouting(t)
|
|
cmd := exec.Command(bin)
|
|
cmd.Env = []string{
|
|
"ROUTING_PORT=" + port,
|
|
"LITELLM_BASE_URL=" + llm.URL,
|
|
"LITELLM_API_KEY=stub",
|
|
"BRAIN_URL=" + brain.URL,
|
|
"SUPERVISOR_CONFIG_DIR=../../config/supervisor",
|
|
"PATH=" + os.Getenv("PATH"),
|
|
"HOME=" + os.Getenv("HOME"),
|
|
}
|
|
require.NoError(t, cmd.Start())
|
|
t.Cleanup(func() { _ = cmd.Process.Kill() })
|
|
|
|
require.NoError(t, waitForPort(t, addr, 30*time.Second))
|
|
|
|
resp := mcpCall(t, baseURL+"/mcp", `{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)
|
|
assert.Contains(t, resp, `"review"`)
|
|
assert.Contains(t, resp, `"debug"`)
|
|
assert.Contains(t, resp, `"retrospective"`)
|
|
assert.Contains(t, resp, `"trainer"`)
|
|
|
|
resp = mcpCall(t, baseURL+"/mcp", `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"review","arguments":{"project_root":"/tmp","files":["README.md"]}}}`)
|
|
_ = resp // shape varies by skill; we only need a 200
|
|
|
|
// Wait briefly for the async session_log to land.
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) && brainHits < 2 {
|
|
time.Sleep(50 * time.Millisecond)
|
|
}
|
|
assert.GreaterOrEqual(t, brainHits, 2, "expected at least one /pass-rate hit and one /mcp session_log hit")
|
|
}
|
|
|
|
func buildRouting(t *testing.T) string {
|
|
t.Helper()
|
|
bin := t.TempDir() + "/routing"
|
|
out, err := exec.Command("go", "build", "-o", bin, "github.com/mathiasbq/supervisor/cmd/routing").CombinedOutput()
|
|
require.NoError(t, err, "build failed: %s", out)
|
|
return bin
|
|
}
|
|
|
|
func waitForPort(_ *testing.T, addr string, dur time.Duration) error {
|
|
deadline := time.Now().Add(dur)
|
|
for time.Now().Before(deadline) {
|
|
c, err := http.Get("http://" + addr + "/healthz") //nolint:noctx
|
|
if err == nil {
|
|
_ = c.Body.Close()
|
|
return nil
|
|
}
|
|
conn, err := http.NewRequest(http.MethodPost, "http://"+addr+"/mcp", strings.NewReader(`{}`))
|
|
if err == nil {
|
|
r, err := http.DefaultClient.Do(conn)
|
|
if err == nil {
|
|
_ = r.Body.Close()
|
|
return nil
|
|
}
|
|
}
|
|
time.Sleep(50 * time.Millisecond)
|
|
}
|
|
return context.DeadlineExceeded
|
|
}
|
|
|
|
func mcpCall(t *testing.T, url, body string) string {
|
|
t.Helper()
|
|
r, err := http.Post(url, "application/json", strings.NewReader(body)) //nolint:noctx
|
|
require.NoError(t, err)
|
|
defer func() { _ = r.Body.Close() }()
|
|
raw, err := io.ReadAll(r.Body)
|
|
require.NoError(t, err)
|
|
return string(raw)
|
|
}
|
|
|
|
// freePort grabs an OS-assigned TCP port and releases it. There is a small
|
|
// race window before the subprocess re-binds it, but it is acceptable for
|
|
// test isolation against a hardcoded port colliding with another test or
|
|
// stray process.
|
|
func freePort(t *testing.T) string {
|
|
t.Helper()
|
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
|
require.NoError(t, err)
|
|
port := l.Addr().(*net.TCPAddr).Port
|
|
require.NoError(t, l.Close())
|
|
return strconv.Itoa(port)
|
|
}
|