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) }