Files
HomeAgent/internal/plugins/webui/handler_test.go
root fab58e709a feat: complete P0/P1/P2 — WebUI SPA, OpenClaw sidecar+simulator, healthcheck auto-sched+perf
P0: WebUI重构
- 完整 SPA 仪表盘 (7标签页), //go:embed dashboard.html
P1: OpenClaw兼容 (三通道: SKILL.md / sidecar / simulator)
- Node.js 模拟进程统一加载任意 OpenClaw 插件
- JSON-RPC 2.0 over stdio 协议, go:embed 内嵌
P2: Healthcheck 优化
- 定时自动执行 (startAutoCheck, 30min)
- healthcheck_perf 性能监控工具

其他: agentcli/cmd 插件, integration_test, status.go,
      test_deepseek 清理, 多项 bug 修复
2026-07-04 12:51:32 +08:00

694 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package webui
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentCore "gitcode.com/JianFeeeee/HomeAgent/internal/agent/core"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
"gitcode.com/JianFeeeee/HomeAgent/internal/supervisor"
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
)
func newTestHandler(t *testing.T) (*Handler, *supervisor.Daemon) {
t.Helper()
cfg := &types.Config{
Daemon: types.DaemonConfig{
CheckInterval: time.Minute,
HeartbeatInterval: 30 * time.Second,
},
}
sup := supervisor.New(cfg)
sup.Start()
return NewHandler(sup, nil, nil, nil, cfg, nil, nil, nil, nil, nil, nil, events.NewBus(), nil), sup
}
func TestHandleStatus(t *testing.T) {
h, sup := newTestHandler(t)
defer sup.Shutdown()
req := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
w := httptest.NewRecorder()
h.handleStatus(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
var resp map[string]interface{}
json.NewDecoder(w.Body).Decode(&resp)
if resp["status"] != "running" {
t.Errorf("expected running, got %v", resp["status"])
}
}
func TestHandleStatusMethodNotAllowed(t *testing.T) {
h, sup := newTestHandler(t)
defer sup.Shutdown()
req := httptest.NewRequest(http.MethodPost, "/api/v1/status", nil)
w := httptest.NewRecorder()
h.handleStatus(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("expected 405, got %d", w.Code)
}
}
func TestHandleAgents(t *testing.T) {
h, sup := newTestHandler(t)
defer sup.Shutdown()
sup.RegisterAgent("test_agent")
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
w := httptest.NewRecorder()
h.handleAgents(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
var resp map[string]interface{}
json.NewDecoder(w.Body).Decode(&resp)
agents, ok := resp["agents"].([]interface{})
if !ok || len(agents) == 0 {
t.Error("expected agents list")
}
}
func TestHandleAgentByID(t *testing.T) {
h, sup := newTestHandler(t)
defer sup.Shutdown()
sup.RegisterAgent("my_agent")
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/my_agent", nil)
w := httptest.NewRecorder()
h.handleAgentByID(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
var resp map[string]interface{}
json.NewDecoder(w.Body).Decode(&resp)
if resp["id"] != "my_agent" {
t.Errorf("expected my_agent, got %v", resp["id"])
}
}
func TestHandleAgentByIDNotFound(t *testing.T) {
h, sup := newTestHandler(t)
defer sup.Shutdown()
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/nonexistent", nil)
w := httptest.NewRecorder()
h.handleAgentByID(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d", w.Code)
}
}
func TestHandleKnowledgeSearch(t *testing.T) {
ks := knowledge.NewStore(t.TempDir())
ks.Start()
ks.Add("test_doc", "this is test content for searching")
cfg := &types.Config{
Daemon: types.DaemonConfig{
CheckInterval: time.Minute,
HeartbeatInterval: 30 * time.Second,
},
}
sup := supervisor.New(cfg)
sup.Start()
defer sup.Shutdown()
h := NewHandler(sup, nil, nil, nil, cfg, nil, nil, ks, nil, nil, nil, events.NewBus(), nil)
req := httptest.NewRequest(http.MethodGet, "/api/v1/knowledge?q=test", nil)
w := httptest.NewRecorder()
h.handleKnowledge(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
var resp map[string]interface{}
json.NewDecoder(w.Body).Decode(&resp)
results, ok := resp["results"].([]interface{})
if !ok || len(results) == 0 {
t.Error("expected search results")
}
}
func TestHandleKnowledgeCreate(t *testing.T) {
ks := knowledge.NewStore(t.TempDir())
ks.Start()
cfg := &types.Config{
Daemon: types.DaemonConfig{
CheckInterval: time.Minute,
HeartbeatInterval: 30 * time.Second,
},
}
sup := supervisor.New(cfg)
sup.Start()
defer sup.Shutdown()
h := NewHandler(sup, nil, nil, nil, cfg, nil, nil, ks, nil, nil, nil, events.NewBus(), nil)
body := `{"name":"new_doc","content":"fresh content"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/knowledge", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleKnowledge(w, req)
if w.Code != http.StatusCreated {
t.Errorf("expected 201, got %d", w.Code)
}
}
func TestHandleKnowledgeUnavailable(t *testing.T) {
h, sup := newTestHandler(t)
defer sup.Shutdown()
req := httptest.NewRequest(http.MethodGet, "/api/v1/knowledge?q=test", nil)
w := httptest.NewRecorder()
h.handleKnowledge(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Errorf("expected 503, got %d", w.Code)
}
}
func TestHandleMemoryUnavailable(t *testing.T) {
h, sup := newTestHandler(t)
defer sup.Shutdown()
req := httptest.NewRequest(http.MethodGet, "/api/v1/memory?q=test", nil)
w := httptest.NewRecorder()
h.handleMemory(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Errorf("expected 503, got %d", w.Code)
}
}
func TestHandleTrackerNotAvailable(t *testing.T) {
h, sup := newTestHandler(t)
defer sup.Shutdown()
req := httptest.NewRequest(http.MethodGet, "/api/v1/tracker", nil)
w := httptest.NewRecorder()
h.handleTracker(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Errorf("expected 503, got %d", w.Code)
}
}
func TestHandleTrackerStats(t *testing.T) {
tr := tracker.NewTracker(t.TempDir(), t.TempDir())
cfg := &types.Config{
Daemon: types.DaemonConfig{
CheckInterval: time.Minute,
HeartbeatInterval: 30 * time.Second,
},
}
sup := supervisor.New(cfg)
sup.Start()
defer sup.Shutdown()
h := NewHandler(sup, nil, nil, nil, cfg, nil, nil, nil, tr, nil, nil, events.NewBus(), nil)
req := httptest.NewRequest(http.MethodGet, "/api/v1/tracker", nil)
w := httptest.NewRecorder()
h.handleTracker(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
}
func TestHandleOpenAICompletionsNoMessages(t *testing.T) {
h, sup := newTestHandler(t)
defer sup.Shutdown()
// 给 handler 一个 IOManager才能通过 nil 检查到达消息校验
h.iom = agentIO.NewIOManager()
body := `{"model":"test"}`
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleOpenAICompletions(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestHandleOpenAICompletionsLastMsgNotUser(t *testing.T) {
h, sup := newTestHandler(t)
defer sup.Shutdown()
// 给 handler 一个 IOManager才能通过 nil 检查到达消息校验
h.iom = agentIO.NewIOManager()
body := `{"messages":[{"role":"assistant","content":"hi"}]}`
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleOpenAICompletions(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestHandleOpenAICompletionsMethodNotAllowed(t *testing.T) {
h, sup := newTestHandler(t)
defer sup.Shutdown()
req := httptest.NewRequest(http.MethodGet, "/v1/chat/completions", nil)
w := httptest.NewRecorder()
h.handleOpenAICompletions(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("expected 405, got %d", w.Code)
}
}
func TestHandleStaticServesHTML(t *testing.T) {
h, sup := newTestHandler(t)
defer sup.Shutdown()
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
h.handleStatic(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
if !strings.Contains(w.Body.String(), "HomeAgent Dashboard") {
t.Error("expected dashboard HTML")
}
}
func TestHandleStaticNotFound(t *testing.T) {
h, sup := newTestHandler(t)
defer sup.Shutdown()
req := httptest.NewRequest(http.MethodGet, "/nonexistent", nil)
w := httptest.NewRecorder()
h.handleStatic(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d", w.Code)
}
}
func TestHandleConfigGet(t *testing.T) {
h, sup := newTestHandler(t)
defer sup.Shutdown()
req := httptest.NewRequest(http.MethodGet, "/api/v1/config", nil)
w := httptest.NewRecorder()
h.handleConfig(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
}
func TestRegisterRoutes(t *testing.T) {
h, sup := newTestHandler(t)
defer sup.Shutdown()
mux := http.NewServeMux()
h.RegisterRoutes(mux)
tests := []struct {
path string
method string
code int
}{
{"/api/v1/status", http.MethodGet, http.StatusOK},
{"/api/v1/agents", http.MethodGet, http.StatusOK},
{"/api/v1/config", http.MethodGet, http.StatusOK},
{"/api/v1/network", http.MethodGet, http.StatusOK},
{"/", http.MethodGet, http.StatusOK},
{"/api/v1/memory", http.MethodGet, http.StatusServiceUnavailable},
{"/api/v1/knowledge", http.MethodGet, http.StatusServiceUnavailable},
{"/api/v1/tracker", http.MethodGet, http.StatusServiceUnavailable},
{"/api/v1/adapters", http.MethodGet, http.StatusServiceUnavailable},
}
for _, tt := range tests {
req := httptest.NewRequest(tt.method, tt.path, nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != tt.code {
t.Errorf("%s %s: expected %d, got %d", tt.method, tt.path, tt.code, w.Code)
}
}
}
func TestHandleAdapterByIDNotFound(t *testing.T) {
h, sup := newTestHandler(t)
defer sup.Shutdown()
req := httptest.NewRequest(http.MethodGet, "/api/v1/adapters/nonexistent", nil)
w := httptest.NewRecorder()
h.handleAdapterByID(w, req)
// Returns 503 when lua VM is not available
if w.Code != http.StatusServiceUnavailable {
t.Errorf("expected 503, got %d", w.Code)
}
}
func TestHandleAdaptersUnavailable(t *testing.T) {
h, sup := newTestHandler(t)
defer sup.Shutdown()
req := httptest.NewRequest(http.MethodGet, "/api/v1/adapters", nil)
w := httptest.NewRecorder()
h.handleAdapters(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Errorf("expected 503, got %d", w.Code)
}
}
// === 全流程集成测试ConfigRegistry → WebUI → HTTP API ===
func TestSettingsAPIFlow(t *testing.T) {
cfgReg := internalConfig.NewConfigRegistry("")
cfgReg.Register("core.llm.model", "deepseek-v4-flash")
cfgReg.Register("core.llm.base_url", "https://api.deepseek.com")
cfgReg.Register("core.daemon.listen_addr", ":8080")
cfgReg.Register("plugin.qq.access_token", "secret123")
sup := supervisor.New(&types.Config{
Daemon: types.DaemonConfig{
CheckInterval: time.Minute,
HeartbeatInterval: 30 * time.Second,
},
})
sup.Start()
defer sup.Shutdown()
pluginReg := plugin.NewRegistry()
h := NewHandler(sup, nil, nil, nil, &types.Config{}, nil, nil, nil, nil, cfgReg, pluginReg, events.NewBus(), nil)
t.Run("GET_settings_lists_keys_and_plugins", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil)
w := httptest.NewRecorder()
h.handleSettings(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
settings, ok := resp["settings"].(map[string]interface{})
if !ok {
t.Fatal("settings not a map")
}
if v, _ := settings["core.llm.model"].(string); v != "deepseek-v4-flash" {
t.Fatalf("expected deepseek-v4-flash, got %v", settings["core.llm.model"])
}
plugins, ok := resp["plugins"].([]interface{})
if !ok || len(plugins) == 0 {
t.Fatal("expected plugins list")
}
if plugins[0] != "core" {
t.Fatalf("expected first plugin 'core', got %v", plugins[0])
}
})
t.Run("GET_settings_with_prefix", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings?prefix=core.daemon", nil)
w := httptest.NewRecorder()
h.handleSettings(w, req)
var resp map[string]interface{}
json.NewDecoder(w.Body).Decode(&resp)
settings := resp["settings"].(map[string]interface{})
if _, ok := settings["core.daemon.listen_addr"]; !ok {
t.Fatal("expected core.daemon.listen_addr in filtered results")
}
if _, ok := settings["core.llm.model"]; ok {
t.Fatal("core.llm.model should not be in core.daemon filtered results")
}
})
t.Run("PUT_settings_updates_value", func(t *testing.T) {
body := `{"key":"core.llm.model","value":"gpt-4"}`
req := httptest.NewRequest(http.MethodPut, "/api/v1/settings", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleSettings(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
val, err := cfgReg.Get("core.llm.model")
if err != nil {
t.Fatalf("Get error: %v", err)
}
if v, _ := val.(string); v != "gpt-4" {
t.Fatalf("expected gpt-4, got %v", val)
}
})
t.Run("PUT_settings_invalid_body", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPut, "/api/v1/settings", strings.NewReader("not json"))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleSettings(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
})
t.Run("handleStatic_returns_webui_html", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
h.handleStatic(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
body, _ := io.ReadAll(w.Body)
html := string(body)
if !strings.Contains(html, "settings-layout") {
t.Fatal("HTML should contain settings-layout class")
}
if !strings.Contains(html, "settings-sidebar") {
t.Fatal("HTML should contain settings-sidebar class")
}
if !strings.Contains(html, "saveSetting") {
t.Fatal("HTML should contain saveSetting JS function")
}
if !strings.Contains(html, "api('/settings'") {
t.Fatal("HTML should call api('/settings')")
}
})
t.Run("settings_not_available_without_registry", func(t *testing.T) {
h2 := NewHandler(sup, nil, nil, nil, &types.Config{}, nil, nil, nil, nil, nil, nil, events.NewBus(), nil)
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil)
w := httptest.NewRecorder()
h2.handleSettings(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
})
}
func TestSettingsWithPluginRegistry(t *testing.T) {
cfgReg := internalConfig.NewConfigRegistry("")
cfgReg.Register("core.test.key", "value")
cfgReg.Register("plugin.testplug.apikey", "abc123")
sup := supervisor.New(&types.Config{
Daemon: types.DaemonConfig{
CheckInterval: time.Minute,
HeartbeatInterval: 30 * time.Second,
},
})
sup.Start()
defer sup.Shutdown()
pluginReg := plugin.NewRegistry()
h := NewHandler(sup, nil, nil, nil, &types.Config{}, nil, nil, nil, nil, cfgReg, pluginReg, events.NewBus(), nil)
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil)
w := httptest.NewRecorder()
h.handleSettings(w, req)
var resp map[string]interface{}
json.NewDecoder(w.Body).Decode(&resp)
plugins, _ := resp["plugins"].([]interface{})
foundCore := false
for _, p := range plugins {
if p == "core" {
foundCore = true
break
}
}
if !foundCore {
t.Fatal("expected 'core' in plugins list")
}
}
// === 端到端测试Handler + IOManager + Agent + HTTP ===
type echoProvider struct{ name string }
func (p *echoProvider) Name() string { return p.name }
func (p *echoProvider) Chat(ctx context.Context, req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
content := "echo: " + req.Messages[len(req.Messages)-1].Content
return &agentAPI.CompletionResponse{Content: content, FinishReason: "stop"}, nil
}
func (p *echoProvider) ChatStream(ctx context.Context, req *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
ch := make(chan agentAPI.StreamChunk, 1)
ch <- agentAPI.StreamChunk{Content: "mock", Done: true}
return ch, nil
}
func init() {
// 避免测试时自动输出
}
func TestHandleCompletionsEndToEnd(t *testing.T) {
iom := agentIO.NewIOManager()
// 启动一个最小 Agent使用 echoProvider不调真实 LLM
memDB, err := memory.NewGraphDB(t.TempDir() + "/graph.db")
if err != nil {
t.Fatalf("NewGraphDB: %v", err)
}
defer memDB.Close()
agent := agentCore.New(agentCore.AgentConfig{
ID: "test",
SystemPrompt: "你是测试助手",
Provider: &echoProvider{name: "echo"},
IO: iom,
Memory: memDB,
Indexer: nil,
MaxToolTurns: 0,
ContextSavePath: "",
})
agent.Start()
defer agent.Stop()
// Handler 需要 iom
sup := supervisor.New(&types.Config{
Daemon: types.DaemonConfig{
CheckInterval: time.Minute,
HeartbeatInterval: 30 * time.Second,
},
})
sup.Start()
defer sup.Shutdown()
h := NewHandler(sup, nil, nil, nil, &types.Config{}, iom, nil, nil, nil, nil, nil, events.NewBus(), nil)
t.Run("POST_chat_completions_returns_echo", func(t *testing.T) {
body := `{"model":"test","messages":[{"role":"user","content":"你好"}]}`
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleOpenAICompletions(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
choices, ok := resp["choices"].([]interface{})
if !ok || len(choices) == 0 {
t.Fatal("expected choices")
}
msg, ok := choices[0].(map[string]interface{})["message"].(map[string]interface{})
if !ok {
t.Fatal("expected message")
}
if msg["content"] != "echo: 你好" {
t.Fatalf("expected 'echo: 你好', got '%v'", msg["content"])
}
})
t.Run("POST_chat_completions_no_iom_returns_503", func(t *testing.T) {
h2 := NewHandler(sup, nil, nil, nil, &types.Config{}, nil, nil, nil, nil, nil, nil, events.NewBus(), nil)
body := `{"messages":[{"role":"user","content":"hi"}]}`
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h2.handleOpenAICompletions(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503, got %d", w.Code)
}
})
t.Run("POST_chat_completions_400_on_no_messages", func(t *testing.T) {
body := `{"model":"test"}`
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleOpenAICompletions(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
})
t.Run("POST_chat_completions_400_on_non_user_last_msg", func(t *testing.T) {
body := `{"messages":[{"role":"assistant","content":"hi"}]}`
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleOpenAICompletions(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
})
}