mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
c69c48ad46
- add a dedicated /api/gateway/logs endpoint for incremental log polling - keep /api/gateway/status focused on runtime and health data only - update frontend log fetching to use the new API and add backend tests covering the status/logs separation and cleared-log behavior
877 lines
24 KiB
Go
877 lines
24 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/sipeed/picoclaw/pkg/auth"
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
|
"github.com/sipeed/picoclaw/web/backend/utils"
|
|
)
|
|
|
|
func startLongRunningProcess(t *testing.T) *exec.Cmd {
|
|
t.Helper()
|
|
|
|
var cmd *exec.Cmd
|
|
if runtime.GOOS == "windows" {
|
|
cmd = exec.Command("powershell", "-NoProfile", "-Command", "Start-Sleep -Seconds 30")
|
|
} else {
|
|
cmd = exec.Command("sleep", "30")
|
|
}
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
t.Fatalf("Start() error = %v", err)
|
|
}
|
|
|
|
return cmd
|
|
}
|
|
|
|
func startIgnoringTermProcess(t *testing.T) *exec.Cmd {
|
|
t.Helper()
|
|
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("TERM handling differs on Windows")
|
|
}
|
|
|
|
cmd := exec.Command("sh", "-c", "trap '' TERM; sleep 30")
|
|
if err := cmd.Start(); err != nil {
|
|
t.Fatalf("Start() error = %v", err)
|
|
}
|
|
|
|
return cmd
|
|
}
|
|
|
|
func resetGatewayTestState(t *testing.T) {
|
|
t.Helper()
|
|
|
|
originalHealthGet := gatewayHealthGet
|
|
originalRestartGracePeriod := gatewayRestartGracePeriod
|
|
originalRestartForceKillWindow := gatewayRestartForceKillWindow
|
|
originalRestartPollInterval := gatewayRestartPollInterval
|
|
t.Cleanup(func() {
|
|
gatewayHealthGet = originalHealthGet
|
|
gatewayRestartGracePeriod = originalRestartGracePeriod
|
|
gatewayRestartForceKillWindow = originalRestartForceKillWindow
|
|
gatewayRestartPollInterval = originalRestartPollInterval
|
|
|
|
gateway.mu.Lock()
|
|
gateway.cmd = nil
|
|
gateway.bootDefaultModel = ""
|
|
setGatewayRuntimeStatusLocked("stopped")
|
|
gateway.mu.Unlock()
|
|
})
|
|
}
|
|
|
|
func TestGatewayStartReady_NoDefaultModel(t *testing.T) {
|
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
|
h := NewHandler(configPath)
|
|
|
|
ready, reason, err := h.gatewayStartReady()
|
|
if err != nil {
|
|
t.Fatalf("gatewayStartReady() error = %v", err)
|
|
}
|
|
if ready {
|
|
t.Fatalf("gatewayStartReady() ready = true, want false")
|
|
}
|
|
if reason != "no default model configured" {
|
|
t.Fatalf("gatewayStartReady() reason = %q, want %q", reason, "no default model configured")
|
|
}
|
|
}
|
|
|
|
func TestGatewayStartReady_InvalidDefaultModel(t *testing.T) {
|
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
|
cfg := config.DefaultConfig()
|
|
cfg.Agents.Defaults.Model = "missing-model"
|
|
err := config.SaveConfig(configPath, cfg)
|
|
if err != nil {
|
|
t.Fatalf("SaveConfig() error = %v", err)
|
|
}
|
|
|
|
h := NewHandler(configPath)
|
|
ready, reason, err := h.gatewayStartReady()
|
|
if err != nil {
|
|
t.Fatalf("gatewayStartReady() error = %v", err)
|
|
}
|
|
if ready {
|
|
t.Fatalf("gatewayStartReady() ready = true, want false")
|
|
}
|
|
if reason == "" {
|
|
t.Fatalf("gatewayStartReady() reason is empty")
|
|
}
|
|
}
|
|
|
|
func TestGatewayStartReady_ValidDefaultModel(t *testing.T) {
|
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
|
cfg := config.DefaultConfig()
|
|
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
|
|
cfg.ModelList[0].APIKey = "test-key"
|
|
err := config.SaveConfig(configPath, cfg)
|
|
if err != nil {
|
|
t.Fatalf("SaveConfig() error = %v", err)
|
|
}
|
|
|
|
h := NewHandler(configPath)
|
|
ready, reason, err := h.gatewayStartReady()
|
|
if err != nil {
|
|
t.Fatalf("gatewayStartReady() error = %v", err)
|
|
}
|
|
if !ready {
|
|
t.Fatalf("gatewayStartReady() ready = false, want true (reason=%q)", reason)
|
|
}
|
|
}
|
|
|
|
func TestGatewayStartReady_DefaultModelWithoutCredential(t *testing.T) {
|
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
|
cfg := config.DefaultConfig()
|
|
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
|
|
cfg.ModelList[0].APIKey = ""
|
|
cfg.ModelList[0].AuthMethod = ""
|
|
err := config.SaveConfig(configPath, cfg)
|
|
if err != nil {
|
|
t.Fatalf("SaveConfig() error = %v", err)
|
|
}
|
|
|
|
h := NewHandler(configPath)
|
|
ready, reason, err := h.gatewayStartReady()
|
|
if err != nil {
|
|
t.Fatalf("gatewayStartReady() error = %v", err)
|
|
}
|
|
if ready {
|
|
t.Fatalf("gatewayStartReady() ready = true, want false")
|
|
}
|
|
if !strings.Contains(reason, "no credentials configured") {
|
|
t.Fatalf("gatewayStartReady() reason = %q, want contains %q", reason, "no credentials configured")
|
|
}
|
|
}
|
|
|
|
func TestGatewayStartReady_LocalModelWithoutAPIKey(t *testing.T) {
|
|
configPath, cleanup := setupOAuthTestEnv(t)
|
|
defer cleanup()
|
|
resetModelProbeHooks(t)
|
|
|
|
probeOpenAICompatibleModelFunc = func(apiBase, modelID string) bool {
|
|
return false
|
|
}
|
|
|
|
cfg, err := config.LoadConfig(configPath)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig() error = %v", err)
|
|
}
|
|
cfg.ModelList = []config.ModelConfig{{
|
|
ModelName: "local-vllm",
|
|
Model: "vllm/custom-model",
|
|
APIBase: "http://localhost:8000/v1",
|
|
}}
|
|
cfg.Agents.Defaults.ModelName = "local-vllm"
|
|
err = config.SaveConfig(configPath, cfg)
|
|
if err != nil {
|
|
t.Fatalf("SaveConfig() error = %v", err)
|
|
}
|
|
|
|
h := NewHandler(configPath)
|
|
ready, reason, err := h.gatewayStartReady()
|
|
if err != nil {
|
|
t.Fatalf("gatewayStartReady() error = %v", err)
|
|
}
|
|
if ready {
|
|
t.Fatalf("gatewayStartReady() ready = true, want false without a running local service")
|
|
}
|
|
if !strings.Contains(reason, "not reachable") {
|
|
t.Fatalf("gatewayStartReady() reason = %q, want contains %q", reason, "not reachable")
|
|
}
|
|
}
|
|
|
|
func TestGatewayStartReady_LocalModelWithRunningService(t *testing.T) {
|
|
configPath, cleanup := setupOAuthTestEnv(t)
|
|
defer cleanup()
|
|
resetModelProbeHooks(t)
|
|
|
|
probeOpenAICompatibleModelFunc = func(apiBase, modelID string) bool {
|
|
return apiBase == "http://127.0.0.1:8000/v1" && modelID == "custom-model"
|
|
}
|
|
|
|
cfg, err := config.LoadConfig(configPath)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig() error = %v", err)
|
|
}
|
|
cfg.ModelList = []config.ModelConfig{{
|
|
ModelName: "local-vllm",
|
|
Model: "vllm/custom-model",
|
|
APIBase: "http://127.0.0.1:8000/v1",
|
|
}}
|
|
cfg.Agents.Defaults.ModelName = "local-vllm"
|
|
err = config.SaveConfig(configPath, cfg)
|
|
if err != nil {
|
|
t.Fatalf("SaveConfig() error = %v", err)
|
|
}
|
|
|
|
h := NewHandler(configPath)
|
|
ready, reason, err := h.gatewayStartReady()
|
|
if err != nil {
|
|
t.Fatalf("gatewayStartReady() error = %v", err)
|
|
}
|
|
if !ready {
|
|
t.Fatalf("gatewayStartReady() ready = false, want true with a running local service (reason=%q)", reason)
|
|
}
|
|
}
|
|
|
|
func TestGatewayStartReady_RemoteVLLMWithAPIKeyDoesNotProbe(t *testing.T) {
|
|
configPath, cleanup := setupOAuthTestEnv(t)
|
|
defer cleanup()
|
|
resetModelProbeHooks(t)
|
|
|
|
probeOpenAICompatibleModelFunc = func(apiBase, modelID string) bool {
|
|
t.Fatalf("unexpected OpenAI-compatible probe for %q (%q)", apiBase, modelID)
|
|
return false
|
|
}
|
|
|
|
cfg, err := config.LoadConfig(configPath)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig() error = %v", err)
|
|
}
|
|
cfg.ModelList = []config.ModelConfig{{
|
|
ModelName: "remote-vllm",
|
|
Model: "vllm/custom-model",
|
|
APIBase: "https://models.example.com/v1",
|
|
APIKey: "remote-key",
|
|
}}
|
|
cfg.Agents.Defaults.ModelName = "remote-vllm"
|
|
err = config.SaveConfig(configPath, cfg)
|
|
if err != nil {
|
|
t.Fatalf("SaveConfig() error = %v", err)
|
|
}
|
|
|
|
h := NewHandler(configPath)
|
|
ready, reason, err := h.gatewayStartReady()
|
|
if err != nil {
|
|
t.Fatalf("gatewayStartReady() error = %v", err)
|
|
}
|
|
if !ready {
|
|
t.Fatalf("gatewayStartReady() ready = false, want true for remote vllm with api key (reason=%q)", reason)
|
|
}
|
|
}
|
|
|
|
func TestGatewayStartReady_LocalOllamaUsesDefaultProbeBase(t *testing.T) {
|
|
configPath, cleanup := setupOAuthTestEnv(t)
|
|
defer cleanup()
|
|
resetModelProbeHooks(t)
|
|
|
|
probeOllamaModelFunc = func(apiBase, modelID string) bool {
|
|
return apiBase == "http://localhost:11434/v1" && modelID == "llama3"
|
|
}
|
|
|
|
cfg, err := config.LoadConfig(configPath)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig() error = %v", err)
|
|
}
|
|
cfg.ModelList = []config.ModelConfig{{
|
|
ModelName: "local-ollama",
|
|
Model: "ollama/llama3",
|
|
}}
|
|
cfg.Agents.Defaults.ModelName = "local-ollama"
|
|
err = config.SaveConfig(configPath, cfg)
|
|
if err != nil {
|
|
t.Fatalf("SaveConfig() error = %v", err)
|
|
}
|
|
|
|
h := NewHandler(configPath)
|
|
ready, reason, err := h.gatewayStartReady()
|
|
if err != nil {
|
|
t.Fatalf("gatewayStartReady() error = %v", err)
|
|
}
|
|
if !ready {
|
|
t.Fatalf("gatewayStartReady() ready = false, want true with default Ollama probe base (reason=%q)", reason)
|
|
}
|
|
}
|
|
|
|
func TestGatewayStartReady_OAuthModelRequiresStoredCredential(t *testing.T) {
|
|
configPath, cleanup := setupOAuthTestEnv(t)
|
|
defer cleanup()
|
|
|
|
cfg, err := config.LoadConfig(configPath)
|
|
if err != nil {
|
|
t.Fatalf("LoadConfig() error = %v", err)
|
|
}
|
|
cfg.ModelList = []config.ModelConfig{{
|
|
ModelName: "openai-oauth",
|
|
Model: "openai/gpt-5.4",
|
|
AuthMethod: "oauth",
|
|
}}
|
|
cfg.Agents.Defaults.ModelName = "openai-oauth"
|
|
err = config.SaveConfig(configPath, cfg)
|
|
if err != nil {
|
|
t.Fatalf("SaveConfig() error = %v", err)
|
|
}
|
|
|
|
h := NewHandler(configPath)
|
|
ready, reason, err := h.gatewayStartReady()
|
|
if err != nil {
|
|
t.Fatalf("gatewayStartReady() error = %v", err)
|
|
}
|
|
if ready {
|
|
t.Fatalf("gatewayStartReady() ready = true, want false without stored credential")
|
|
}
|
|
if !strings.Contains(reason, "no credentials configured") {
|
|
t.Fatalf("gatewayStartReady() reason = %q, want contains %q", reason, "no credentials configured")
|
|
}
|
|
|
|
err = auth.SetCredential(oauthProviderOpenAI, &auth.AuthCredential{
|
|
AccessToken: "openai-token",
|
|
Provider: oauthProviderOpenAI,
|
|
AuthMethod: "oauth",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("SetCredential() error = %v", err)
|
|
}
|
|
|
|
ready, reason, err = h.gatewayStartReady()
|
|
if err != nil {
|
|
t.Fatalf("gatewayStartReady() error = %v", err)
|
|
}
|
|
if !ready {
|
|
t.Fatalf("gatewayStartReady() ready = false, want true with stored credential (reason=%q)", reason)
|
|
}
|
|
}
|
|
|
|
func TestGatewayStatusIncludesStartConditionWhenNotReady(t *testing.T) {
|
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
|
h := NewHandler(configPath)
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
|
|
mux.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
|
}
|
|
|
|
var body map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("unmarshal response: %v", err)
|
|
}
|
|
|
|
allowed, ok := body["gateway_start_allowed"].(bool)
|
|
if !ok {
|
|
t.Fatalf("gateway_start_allowed missing or not bool: %#v", body["gateway_start_allowed"])
|
|
}
|
|
if allowed {
|
|
t.Fatalf("gateway_start_allowed = true, want false")
|
|
}
|
|
if _, ok := body["gateway_start_reason"].(string); !ok {
|
|
t.Fatalf("gateway_start_reason missing or not string: %#v", body["gateway_start_reason"])
|
|
}
|
|
}
|
|
|
|
func TestGatewayStatusKeepsRunningWhenHealthProbeFailsAfterRunning(t *testing.T) {
|
|
resetGatewayTestState(t)
|
|
|
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
|
h := NewHandler(configPath)
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
cmd := startLongRunningProcess(t)
|
|
t.Cleanup(func() {
|
|
if cmd.Process != nil {
|
|
_ = cmd.Process.Kill()
|
|
}
|
|
_ = cmd.Wait()
|
|
})
|
|
|
|
gateway.mu.Lock()
|
|
gateway.cmd = cmd
|
|
gateway.bootDefaultModel = "existing-model"
|
|
// Simulate a process that has already reached the running state.
|
|
setGatewayRuntimeStatusLocked("running")
|
|
gateway.mu.Unlock()
|
|
|
|
gatewayHealthGet = func(string, time.Duration) (*http.Response, error) {
|
|
return nil, errors.New("probe failed")
|
|
}
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
|
|
mux.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
|
}
|
|
|
|
var body map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("unmarshal response: %v", err)
|
|
}
|
|
|
|
if got := body["gateway_status"]; got != "running" {
|
|
t.Fatalf("gateway_status = %#v, want %q", got, "running")
|
|
}
|
|
}
|
|
|
|
func TestGatewayStatusReturnsErrorAfterStartupWindowExpires(t *testing.T) {
|
|
resetGatewayTestState(t)
|
|
|
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
|
h := NewHandler(configPath)
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
cmd := startLongRunningProcess(t)
|
|
t.Cleanup(func() {
|
|
if cmd.Process != nil {
|
|
_ = cmd.Process.Kill()
|
|
}
|
|
_ = cmd.Wait()
|
|
})
|
|
|
|
gateway.mu.Lock()
|
|
gateway.cmd = cmd
|
|
gateway.bootDefaultModel = "existing-model"
|
|
setGatewayRuntimeStatusLocked("starting")
|
|
gateway.startupDeadline = time.Now().Add(-time.Second)
|
|
gateway.mu.Unlock()
|
|
|
|
gatewayHealthGet = func(string, time.Duration) (*http.Response, error) {
|
|
return nil, errors.New("probe failed")
|
|
}
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
|
|
mux.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
|
}
|
|
|
|
var body map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("unmarshal response: %v", err)
|
|
}
|
|
|
|
if got := body["gateway_status"]; got != "error" {
|
|
t.Fatalf("gateway_status = %#v, want %q", got, "error")
|
|
}
|
|
}
|
|
|
|
func TestGatewayStatusReturnsRestartingDuringRestartGap(t *testing.T) {
|
|
resetGatewayTestState(t)
|
|
|
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
|
h := NewHandler(configPath)
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
gateway.mu.Lock()
|
|
setGatewayRuntimeStatusLocked("restarting")
|
|
gateway.mu.Unlock()
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
|
|
mux.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
|
}
|
|
|
|
var body map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("unmarshal response: %v", err)
|
|
}
|
|
|
|
if got := body["gateway_status"]; got != "restarting" {
|
|
t.Fatalf("gateway_status = %#v, want %q", got, "restarting")
|
|
}
|
|
}
|
|
|
|
func TestGatewayStatusIncludesRestartRequiredWhenModelsDiffer(t *testing.T) {
|
|
resetGatewayTestState(t)
|
|
|
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
|
cfg := config.DefaultConfig()
|
|
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
|
|
cfg.ModelList[0].APIKey = "test-key"
|
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
|
t.Fatalf("SaveConfig() error = %v", err)
|
|
}
|
|
|
|
h := NewHandler(configPath)
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
cmd := startLongRunningProcess(t)
|
|
t.Cleanup(func() {
|
|
if cmd.Process != nil {
|
|
_ = cmd.Process.Kill()
|
|
}
|
|
_ = cmd.Wait()
|
|
})
|
|
|
|
gateway.mu.Lock()
|
|
gateway.cmd = cmd
|
|
gateway.bootDefaultModel = "previous-model"
|
|
setGatewayRuntimeStatusLocked("running")
|
|
gateway.mu.Unlock()
|
|
|
|
gatewayHealthGet = func(string, time.Duration) (*http.Response, error) {
|
|
rec := httptest.NewRecorder()
|
|
rec.WriteHeader(http.StatusOK)
|
|
_, _ = rec.WriteString(`{"ok":true}`)
|
|
return rec.Result(), nil
|
|
}
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
|
|
mux.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
|
}
|
|
|
|
var body map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("unmarshal response: %v", err)
|
|
}
|
|
|
|
if got := body["gateway_restart_required"]; got != true {
|
|
t.Fatalf("gateway_restart_required = %#v, want true", got)
|
|
}
|
|
}
|
|
|
|
func TestGatewayRestartKeepsRunningProcessWhenPreconditionsFail(t *testing.T) {
|
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
|
cfg := config.DefaultConfig()
|
|
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
|
|
cfg.ModelList[0].APIKey = ""
|
|
cfg.ModelList[0].AuthMethod = ""
|
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
|
t.Fatalf("SaveConfig() error = %v", err)
|
|
}
|
|
|
|
h := NewHandler(configPath)
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
cmd := startLongRunningProcess(t)
|
|
t.Cleanup(func() {
|
|
gateway.mu.Lock()
|
|
if gateway.cmd == cmd {
|
|
gateway.cmd = nil
|
|
gateway.bootDefaultModel = ""
|
|
}
|
|
gateway.mu.Unlock()
|
|
|
|
if cmd.Process != nil {
|
|
_ = cmd.Process.Kill()
|
|
}
|
|
_ = cmd.Wait()
|
|
})
|
|
|
|
gateway.mu.Lock()
|
|
gateway.cmd = cmd
|
|
gateway.bootDefaultModel = "existing-model"
|
|
gateway.mu.Unlock()
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/gateway/restart", nil)
|
|
mux.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
|
}
|
|
|
|
gateway.mu.Lock()
|
|
stillRunning := gateway.cmd == cmd && isCmdProcessAliveLocked(cmd)
|
|
gateway.mu.Unlock()
|
|
|
|
if !stillRunning {
|
|
t.Fatalf("gateway process was stopped when restart preconditions failed")
|
|
}
|
|
}
|
|
|
|
func TestGatewayRestartKeepsOldProcessWhenItDoesNotExitInTime(t *testing.T) {
|
|
resetGatewayTestState(t)
|
|
|
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
|
cfg := config.DefaultConfig()
|
|
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
|
|
cfg.ModelList[0].APIKey = "test-key"
|
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
|
t.Fatalf("SaveConfig() error = %v", err)
|
|
}
|
|
|
|
h := NewHandler(configPath)
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
cmd := startIgnoringTermProcess(t)
|
|
t.Cleanup(func() {
|
|
gateway.mu.Lock()
|
|
if gateway.cmd == cmd {
|
|
gateway.cmd = nil
|
|
gateway.bootDefaultModel = ""
|
|
}
|
|
gateway.mu.Unlock()
|
|
|
|
if cmd.Process != nil {
|
|
_ = cmd.Process.Kill()
|
|
}
|
|
_ = cmd.Wait()
|
|
})
|
|
|
|
gatewayRestartGracePeriod = 150 * time.Millisecond
|
|
gatewayRestartForceKillWindow = 150 * time.Millisecond
|
|
gatewayRestartPollInterval = 10 * time.Millisecond
|
|
|
|
gateway.mu.Lock()
|
|
gateway.cmd = cmd
|
|
gateway.bootDefaultModel = "existing-model"
|
|
setGatewayRuntimeStatusLocked("running")
|
|
gateway.mu.Unlock()
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/gateway/restart", nil)
|
|
mux.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusInternalServerError {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusInternalServerError)
|
|
}
|
|
|
|
gateway.mu.Lock()
|
|
stillRunning := gateway.cmd == cmd && isCmdProcessAliveLocked(cmd)
|
|
status := gateway.runtimeStatus
|
|
gateway.mu.Unlock()
|
|
|
|
if !stillRunning {
|
|
t.Fatalf("gateway process was replaced before the old process exited")
|
|
}
|
|
if status != "running" {
|
|
t.Fatalf("runtimeStatus = %q, want %q", status, "running")
|
|
}
|
|
}
|
|
|
|
func TestGatewayRestartReturnsErrorStatusWhenReplacementFailsToStart(t *testing.T) {
|
|
resetGatewayTestState(t)
|
|
|
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
|
cfg := config.DefaultConfig()
|
|
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
|
|
cfg.ModelList[0].APIKey = "test-key"
|
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
|
t.Fatalf("SaveConfig() error = %v", err)
|
|
}
|
|
|
|
invalidBinaryPath := filepath.Join(t.TempDir(), "fake-picoclaw")
|
|
if err := os.WriteFile(invalidBinaryPath, []byte("#!/bin/sh\n"), 0o644); err != nil {
|
|
t.Fatalf("WriteFile() error = %v", err)
|
|
}
|
|
t.Setenv("PICOCLAW_BINARY", invalidBinaryPath)
|
|
|
|
h := NewHandler(configPath)
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/api/gateway/restart", nil)
|
|
mux.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusInternalServerError {
|
|
t.Fatalf("restart status = %d, want %d", rec.Code, http.StatusInternalServerError)
|
|
}
|
|
|
|
statusRec := httptest.NewRecorder()
|
|
statusReq := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
|
|
mux.ServeHTTP(statusRec, statusReq)
|
|
|
|
if statusRec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d", statusRec.Code, http.StatusOK)
|
|
}
|
|
|
|
var body map[string]any
|
|
if err := json.Unmarshal(statusRec.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("unmarshal response: %v", err)
|
|
}
|
|
|
|
if got := body["gateway_status"]; got != "error" {
|
|
t.Fatalf("gateway_status = %#v, want %q", got, "error")
|
|
}
|
|
}
|
|
|
|
func TestGatewayStatusExcludesLogsFields(t *testing.T) {
|
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
|
h := NewHandler(configPath)
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
|
|
mux.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
|
}
|
|
|
|
var body map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("unmarshal response: %v", err)
|
|
}
|
|
|
|
if _, ok := body["logs"]; ok {
|
|
t.Fatalf("logs unexpectedly present in status response: %#v", body["logs"])
|
|
}
|
|
if _, ok := body["log_total"]; ok {
|
|
t.Fatalf("log_total unexpectedly present in status response: %#v", body["log_total"])
|
|
}
|
|
if _, ok := body["log_run_id"]; ok {
|
|
t.Fatalf("log_run_id unexpectedly present in status response: %#v", body["log_run_id"])
|
|
}
|
|
}
|
|
|
|
func TestGatewayLogsReturnsIncrementalHistory(t *testing.T) {
|
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
|
h := NewHandler(configPath)
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
gateway.logs.Clear()
|
|
gateway.logs.Append("first line")
|
|
gateway.logs.Append("second line")
|
|
runID := gateway.logs.RunID()
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(
|
|
http.MethodGet,
|
|
"/api/gateway/logs?log_offset=1&log_run_id="+strconv.Itoa(runID),
|
|
nil,
|
|
)
|
|
mux.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("logs status = %d, want %d", rec.Code, http.StatusOK)
|
|
}
|
|
|
|
var body map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("unmarshal logs response: %v", err)
|
|
}
|
|
|
|
logs, ok := body["logs"].([]any)
|
|
if !ok {
|
|
t.Fatalf("logs missing or not array: %#v", body["logs"])
|
|
}
|
|
if len(logs) != 1 || logs[0] != "second line" {
|
|
t.Fatalf("logs = %#v, want [\"second line\"]", logs)
|
|
}
|
|
if got := body["log_total"]; got != float64(2) {
|
|
t.Fatalf("log_total = %#v, want 2", got)
|
|
}
|
|
if got := body["log_run_id"]; got != float64(runID) {
|
|
t.Fatalf("log_run_id = %#v, want %d", got, runID)
|
|
}
|
|
}
|
|
|
|
func TestGatewayClearLogsResetsBufferedHistory(t *testing.T) {
|
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
|
h := NewHandler(configPath)
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
gateway.logs.Clear()
|
|
gateway.logs.Append("first line")
|
|
gateway.logs.Append("second line")
|
|
previousRunID := gateway.logs.RunID()
|
|
|
|
clearRec := httptest.NewRecorder()
|
|
clearReq := httptest.NewRequest(http.MethodPost, "/api/gateway/logs/clear", nil)
|
|
mux.ServeHTTP(clearRec, clearReq)
|
|
|
|
if clearRec.Code != http.StatusOK {
|
|
t.Fatalf("clear status = %d, want %d", clearRec.Code, http.StatusOK)
|
|
}
|
|
|
|
var clearBody map[string]any
|
|
if err := json.Unmarshal(clearRec.Body.Bytes(), &clearBody); err != nil {
|
|
t.Fatalf("unmarshal clear response: %v", err)
|
|
}
|
|
|
|
if got := clearBody["status"]; got != "cleared" {
|
|
t.Fatalf("clear status body = %#v, want %q", got, "cleared")
|
|
}
|
|
|
|
clearRunID, ok := clearBody["log_run_id"].(float64)
|
|
if !ok {
|
|
t.Fatalf("log_run_id missing or not number: %#v", clearBody["log_run_id"])
|
|
}
|
|
if int(clearRunID) <= previousRunID {
|
|
t.Fatalf("log_run_id = %d, want > %d", int(clearRunID), previousRunID)
|
|
}
|
|
|
|
logsRec := httptest.NewRecorder()
|
|
logsReq := httptest.NewRequest(
|
|
http.MethodGet,
|
|
"/api/gateway/logs?log_offset=0&log_run_id="+strconv.Itoa(previousRunID),
|
|
nil,
|
|
)
|
|
mux.ServeHTTP(logsRec, logsReq)
|
|
|
|
if logsRec.Code != http.StatusOK {
|
|
t.Fatalf("logs code = %d, want %d", logsRec.Code, http.StatusOK)
|
|
}
|
|
|
|
var logsBody map[string]any
|
|
if err := json.Unmarshal(logsRec.Body.Bytes(), &logsBody); err != nil {
|
|
t.Fatalf("unmarshal logs response: %v", err)
|
|
}
|
|
|
|
logs, ok := logsBody["logs"].([]any)
|
|
if !ok {
|
|
t.Fatalf("logs missing or not array: %#v", logsBody["logs"])
|
|
}
|
|
if len(logs) != 0 {
|
|
t.Fatalf("logs len = %d, want 0", len(logs))
|
|
}
|
|
if got := logsBody["log_total"]; got != float64(0) {
|
|
t.Fatalf("log_total = %#v, want 0", got)
|
|
}
|
|
if got := logsBody["log_run_id"]; got != clearBody["log_run_id"] {
|
|
t.Fatalf("log_run_id = %#v, want %#v", got, clearBody["log_run_id"])
|
|
}
|
|
}
|
|
|
|
func TestFindPicoclawBinary_EnvOverride(t *testing.T) {
|
|
// Create a temporary file to act as the mock binary
|
|
tmpDir := t.TempDir()
|
|
mockBinary := filepath.Join(tmpDir, "picoclaw-mock")
|
|
if err := os.WriteFile(mockBinary, []byte("mock"), 0o755); err != nil {
|
|
t.Fatalf("WriteFile() error = %v", err)
|
|
}
|
|
|
|
t.Setenv("PICOCLAW_BINARY", mockBinary)
|
|
|
|
got := utils.FindPicoclawBinary()
|
|
if got != mockBinary {
|
|
t.Errorf("FindPicoclawBinary() = %q, want %q", got, mockBinary)
|
|
}
|
|
}
|
|
|
|
func TestFindPicoclawBinary_EnvOverride_InvalidPath(t *testing.T) {
|
|
// When PICOCLAW_BINARY points to a non-existent path, fall through to next strategy
|
|
t.Setenv("PICOCLAW_BINARY", "/nonexistent/picoclaw-binary")
|
|
|
|
got := utils.FindPicoclawBinary()
|
|
// Should not return the invalid path; falls back to "picoclaw" or another found path
|
|
if got == "/nonexistent/picoclaw-binary" {
|
|
t.Errorf("FindPicoclawBinary() returned invalid env path %q, expected fallback", got)
|
|
}
|
|
}
|